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();
}
@@ -0,0 +1,74 @@
# Apply Progress: premium-award-ui-icons-code-review
## Status
Partial apply completed in automatic multi-agent mode.
## Completed
- Planned and persisted hybrid SDD artifacts: proposal, spec, design, tasks, state.
- Added premium foundations:
- `lib/tema/pluriwave_tokens.dart`
- `lib/tema/pluriwave_motion.dart`
- `lib/tema/pluriwave_theme.dart`
- `lib/widgets/pluri_icon.dart`
- `lib/widgets/pluri_glass_surface.dart`
- `lib/widgets/pluri_wave_scaffold.dart`
- Connected app theme and primary navigation to PluriWave premium foundations in `lib/app.dart`.
- Hardened app error stream subscription in `lib/app.dart` with a single cancelable `StreamSubscription`.
- Hardened audio visualizer stream subscriptions in `lib/widgets/visualizador_audio.dart`.
- Fixed persistent favorites reorder by reindexing the full list in a DB transaction.
- Reduced stale favorite state by deriving favorite UI from `EstadoRadio` in station card and player.
- Made player prefer effective `estado.emisoraActual ?? widget.emisora`.
- Made timer cancellation await audio stop when requested.
- Added/updated tests for premium foundations, visualizer lifecycle, favorite reorder, and favorite sync.
## Verification
- ✅ Sub-agent reported `flutter test test/widgets/pluriwave_foundations_test.dart` passed.
- ✅ Sub-agent reported `flutter test test/estado/estado_radio_test.dart -r compact` passed.
- ✅ Sub-agent reported `flutter test test/widget_test.dart -r compact` passed.
- ⚠️ Full `flutter test` timed out in this environment.
- ⚠️ Local `dart format` and focused Flutter test commands later timed out/hung; stale Dart/Flutter processes were stopped.
- 🚫 No `flutter build` was executed.
## Remaining
- Run full `flutter test` in CI/local with a longer timeout.
- Run `dart format` once Dart tooling is responsive.
- Continue premium screen refresh across Inicio/Buscar/Favoritos/Reproductor/Ajustes.
- Generate or integrate validated icon/mockup assets through the asset pipeline.
- Verify accessibility semantics/tap targets after screen integration.
## Continuation 2026-05-20
### Completed in this continuation
- Premium component refresh:
- `lib/widgets/tarjeta_emisora.dart`
- `lib/widgets/mini_reproductor.dart`
- Premium screen refresh:
- `lib/pantallas/pantalla_inicio.dart`
- `lib/pantallas/pantalla_buscar.dart`
- `lib/pantallas/pantalla_favoritos.dart`
- `lib/pantallas/pantalla_ajustes.dart`
- Premium player/EQ refresh:
- `lib/pantallas/pantalla_reproductor.dart`
- `lib/widgets/ecualizador_widget.dart`
- Persisted dedicated mockup/icon prompts:
- `openspec/changes/premium-award-ui-icons-code-review/mockup-prompts.md`
- Static verification found a critical unsafe `Dismissible` + async Provider removal in Favorites; fixed by replacing swipe-dismiss with explicit delete action.
### Verification in continuation
- ✅ Sub-agent reported `flutter test test/estado/estado_radio_test.dart` passed with 8 tests.
- ✅ Static `git diff --check` completed without whitespace errors.
- ⚠️ `dart format`, `dart analyze`, and focused Flutter tests continued to timeout in this environment.
- 🚫 No `flutter build` executed.
### Remaining
- Run `dart format` and full `flutter test` in responsive local/CI tooling.
- Optional: implement optimistic favorite reorder for smoother premium UX.
- Optional: remove double glass nesting around Ajustes/EQ if visual QA shows heaviness.
- Generate actual raster mockup/icon assets using `mockup-prompts.md` and validate before integration.
@@ -0,0 +1,264 @@
# Design: premium-award-ui-icons-code-review
## Objetivo
Elevar PluriWave a una maqueta/UI premium de nivel award: radio global, audio vivo, favoritos, búsqueda, reproductor, EQ y ajustes con una dirección visual propia: **“Ondas vivas globales”**.
La UI debe sentirse como lujo digital oscuro: vidrio profundo, ondas concéntricas, energía magenta/coral y una familia de iconos propia. Primero fundamentos: **tokens, tema, componentes e iconografía**. Nada de maquillaje suelto arriba de widgets existentes.
## Contexto técnico verificado
- App Flutter/Dart con `Provider` + `ChangeNotifier`.
- Tema actual en `lib/app.dart` usa Material 3 dark + `ColorScheme.fromSeed`.
- Navegación actual usa `Icons.*` de Material.
- Assets declarados: `assets/images/` y `assets/icons/`.
- Tests existentes bajo `test/`.
- Riesgo actual a hardenear: `_PaginaPrincipalState.didChangeDependencies()` escucha `errorStream` sin guardar/cancelar suscripción local; debe moverse a `initState` con `StreamSubscription` y cancelarse en `dispose`.
## Dirección de arte
### Concepto
**Ondas vivas globales**: PluriWave conecta emisoras del mundo a través de ondas sonoras visibles, como una red orbital sobre vidrio oscuro.
Visualmente:
- Fondo oscuro violeta-negro.
- Esferas/global waves con gradientes controlados.
- Superficies glassmorphism sobrias, no “neón barato”.
- Brillos premium: pocos, intencionales, con blur suave.
- Iconos coherentes: ondas concéntricas + geometría redondeada.
### Paleta base
Definir tokens, no colores hardcodeados.
```dart
voidBlack: #07040F
deepViolet: #140722
midnightPurple: #211039
royalViolet: #6E35FF
electricMagenta: #FF2BD6
warmCoral: #FF7A59
softRose: #FFD0E8
auroraCyan: #35E8FF
glassWhite: rgba(255,255,255,0.08)
glassStroke: rgba(255,255,255,0.16)
dangerCoral: #FF5A6A
successMint: #5FFFCB
```
### Gradientes
- `heroGlobalWave`: deep violet → royal violet → magenta.
- `activeGlow`: electric magenta → warm coral.
- `glassSurface`: white 10% → white 4%.
- `playerOrb`: royal violet → magenta → coral.
- `eqSpectrum`: cyan → magenta → coral.
### Tipografía
Mantener Inter si ya está instalado, pero con escala propia:
- Display: hero/player titles.
- Title: cards, screen headers.
- Body: station metadata.
- Label: chips, nav, settings.
Decisión: tipografía sobria. El lujo viene de composición, contraste y ritmo, no de fuentes extravagantes.
## Tokens y tema
Crear capa de tema propia:
```text
lib/tema/pluriwave_tokens.dart
lib/tema/pluriwave_theme.dart
lib/tema/pluriwave_motion.dart
```
Usar `ThemeExtension` para colores premium, gradientes, radios, blur/elevation/glow, spacing y duraciones de motion.
Tokens sugeridos:
```text
spacing: 4, 8, 12, 16, 20, 24, 32
radius: 12, 16, 20, 28, 999
stroke: 1, 1.5, 2
blur: 12, 20, 32
glow: subtle, active, hero
motion: fast 120ms, base 220ms, expressive 420ms
```
Regla: cualquier pantalla/componente nuevo consume tokens. No hardcodear paleta en widgets.
## Sistema de componentes
### App shell
- `PluriWaveScaffold`.
- Fondo premium persistente: gradiente oscuro base, ondas radiales sutiles, puntos/orbitas globales con opacidad baja.
- Respeta safe areas.
- Evita rebuilds caros del fondo.
### Superficies
- `PluriGlassSurface`: color glass tokenizado, borde translúcido, blur moderado, sombra/glow opcional.
- Variantes: `subtle`, `elevated`, `active`, `danger`.
### Navegación
- Reemplazar Material Icons por `PluriIcon`.
- Bottom nav glass flotante.
- Estado activo: icono filled, glow magenta/coral, label con contraste alto.
- Estado inactivo: outline, baja opacidad, pero accesible.
### Tarjeta de emisora
- `PluriStationCard` con nombre, país/idioma/tag, estado reproduciendo, favorito y mini onda/visualizer.
- Variantes: compacta, destacada, reorderable favorite.
- Debe tener semantics claros: estación, país, acción reproducir, favorito.
### Mini reproductor
- Glass pill persistente.
- Orb/onda animada cuando reproduce.
- Botón play/pause con `PluriIcon.playerOrb`.
- Evitar animaciones pesadas; envolver visualizer en `RepaintBoundary`.
### Reproductor
- Pantalla hero con globo/orbe de ondas, metadata de emisora, controles primarios, acceso EQ, favorito y timer.
- La jerarquía debe hacer obvio qué está sonando.
### EQ
- `PluriEqPanel`.
- Sliders con onda/spectrum.
- Presets como chips premium.
- Toggle “preset propio de esta emisora”.
- Estado unavailable accesible cuando EQ no está disponible.
### Estados vacíos/error
- No usar pantallas muertas.
- Empty state con icono propio + copy corto.
- Error offline con acción retry.
- Loading con shimmer sutil.
## Pantallas
### Inicio
- Hero “PluriWave” con globo de ondas.
- Sección Tendencias.
- Sección Populares.
- Accesos rápidos por país/tag.
- Mini reproductor siempre abajo si hay emisora activa.
### Buscar
- Search premium con chips: país, idioma, tag.
- Loading controlado.
- Evitar stale results: la última búsqueda gana.
### Favoritos
- Lista reorderable.
- Drag handle propio.
- Persistencia de orden.
- Si falla persistencia: rollback visual y snackbar.
- Evitar duplicados por `uuid`.
### Reproductor
- Foco absoluto en la emisora actual.
- Controles grandes y accesibles.
- Visualizer vivo, pero pausado/reducido si accesibilidad reduce motion.
### Ajustes
- Cards agrupadas: audio/EQ, import/export, timer, app info.
- Iconos propios por grupo.
## Sistema de iconos
Crear `PluriIcon` como contrato visual propio, con variantes `outline`, `filled`, `activeGlow`.
Iconos core:
```text
homeWave, searchOrbit, heartSignal, playerOrb, eqConstellation,
settingsPrism, sleepMoonWave, globeRadio, stationTower,
importWave, exportWave, timerRing, errorPulse
```
Geometría:
- Grid base 24x24.
- Stroke 2px.
- Caps y joins redondeados.
- Motivo común: ondas concéntricas/orbitas.
- Filled no debe ser solo outline pintado.
- Active glow se logra con composición, no con bitmap borroso incontrolable.
Implementación recomendada:
- Core UI icons: code-native Flutter widgets/painters o assets vectoriales controlados.
- Assets generados por IA: usar para hero, mockups, marketing, splash o iconos complejos, pasando por pipeline de validación.
- Tradeoff: los iconos code-native son menos “ilustración AI”, pero ganan recolor, accesibilidad, consistencia y tests.
## Prompt para mockup premium
```text
Create a premium mobile app UI mockup for "PluriWave", a global radio streaming app.
Art direction: living global audio waves, luxury dark glass, deep violet black background, electric magenta, royal violet, warm coral highlights, subtle cyan accents.
The app includes home, search, favorites, now playing, equalizer, and settings surfaces.
Use elegant glassmorphism, restrained glow, layered depth, rounded premium cards, orbiting wave motifs, global radio map energy, custom wave-based icons, outline and filled active icon states.
Make it look like an award-winning product design case study, polished, editorial, cinematic, high-end digital luxury.
No fake brand logos, no illegible paragraphs, minimal readable labels only: Inicio, Buscar, Favoritos, Ajustes, PluriWave.
Vertical smartphone composition, 9:16, crisp details, professional product mockup, dark premium UI, controlled highlights, no clutter.
```
## Prompt para icon sheet
```text
Generate a 4x4 icon sheet for a premium global radio app called PluriWave.
Style: custom icon family, rounded geometry, concentric audio waves, globe/orbit motifs, deep violet strokes with electric magenta and warm coral active accents.
Icons: home wave, search orbit, heart signal, player orb, equalizer constellation, settings prism, sleep moon wave, globe radio, station tower, import wave, export wave, timer ring, error pulse, favorite filled, play filled, pause filled.
Each icon must be centered in its own cell, isolated, same visual weight, no text.
Use pure chroma green #00FF00 as the full background, with thick #00FF00 gutters and outer border between every icon.
No icon, shadow, glow, or stroke may touch or cross gutters.
Clean high-resolution sheet, sharp edges, premium app icon design, consistent family.
```
Negative prompt:
```text
No text, no letters, no logos, no watermark, no overlapping cells, no cropped icons, no icons touching borders, no inconsistent perspective, no messy neon, no excessive glow, no background other than pure #00FF00.
```
## Asset pipeline
Pipeline obligatorio:
```text
generate → detect gutters → crop cells → chroma-to-alpha → trim transparent bounds → center subject → pad to target canvas → export PNG/WebP → generate contact sheet → validate
```
Validaciones:
- No píxeles #00FF00 residuales.
- Ningún subject toca borde.
- Bounding box razonable: no menor a 45% ni mayor a 82% del canvas.
- Centro visual estable.
- Familia coherente en peso, radio, glow y escala.
- Rechazar sheet malo; no “arreglar a mano” crops rotos.
## Hardening técnico
### Listener leaks
Mover listeners de UI desde `didChangeDependencies` a `initState` cuando no dependan de InheritedWidget dinámico, guardar `StreamSubscription`, cancelar en `dispose`.
### Estado stale
Para búsqueda/carga async: usar request id incremental o token de última operación, ignorar respuestas viejas, limpiar loading solo si la respuesta corresponde al request vigente, evitar `notifyListeners` después de dispose.
### Favoritos reorder
Persistir orden explícito, reorder optimista con rollback si falla, nunca duplicar `uuid`, tests para reorder, remove, import/export y estado actual.
### Accesibilidad
Labels semánticos en iconos, tap targets mínimos 48x48, contraste suficiente, reducir/parar animaciones si `MediaQuery.disableAnimations` o reduced motion, no comunicar estado activo solo con color/glow.
### Performance
`const` donde aplique, `RepaintBoundary` en visualizers/glows animados, blur limitado y tokenizado, precache de assets críticos, evitar rebuilds globales innecesarios del player/nav.
@@ -0,0 +1,38 @@
# Mockup / Asset Prompts
## Premium product mockup prompt
```text
Create a premium mobile app UI mockup for "PluriWave", a global radio streaming app.
Art direction: living global audio waves, luxury dark glass, deep violet black background, electric magenta, royal violet, warm coral highlights, subtle cyan accents.
Show an award-winning app case study composition with five coherent surfaces: Inicio, Buscar, Favoritos, Reproductor, Ajustes.
Design language: elegant glassmorphism, restrained glow, layered depth, rounded premium cards, orbiting wave motifs, global radio map energy, custom wave-based icons, outline and filled active icon states.
The Reproductor screen is the hero: cinematic orb/global wave artwork, clear station metadata, premium playback controls, equalizer entry, favorites, timer.
Use only short readable Spanish labels: PluriWave, Inicio, Buscar, Favoritos, Ajustes, En vivo, Ecualizador.
Vertical smartphone product mockup, 9:16, crisp details, high-end digital luxury, professional design award quality, no clutter.
```
## Icon sheet prompt
```text
Generate a 4x4 icon sheet for a premium global radio app called PluriWave.
Style: custom app icon family, rounded geometry, consistent 2px optical stroke, concentric audio waves, globe/orbit motifs, deep violet strokes with electric magenta and warm coral active accents.
Icons in row-major order: home wave, search orbit, heart signal, player orb, equalizer constellation, settings prism, sleep moon wave, globe radio, station tower, import wave, export wave, timer ring, error pulse, favorite filled, play filled, pause filled.
Each icon must be centered in its own cell, isolated, same visual weight, no text.
Use pure chroma green #00FF00 as the full background, with thick #00FF00 gutters and outer border between every icon.
No icon, shadow, glow, or stroke may touch or cross gutters.
Clean high-resolution sheet, sharp edges, premium app icon design, consistent family.
```
## Negative prompt
```text
No text, no letters, no logos, no watermark, no overlapping cells, no cropped icons, no icons touching borders, no inconsistent perspective, no messy neon, no excessive glow, no background other than pure #00FF00.
```
## Acceptance criteria
- Mockup conveys one coherent product, not isolated random screens.
- Dark glass surfaces remain legible and accessible.
- Icon family has consistent stroke, radius, optical weight, and center alignment.
- Any generated icon sheet MUST pass the validate pipeline before integration.
@@ -0,0 +1,75 @@
# Proposal: Premium award UI, icons, and code-review hardening
## Intent
Elevar PluriWave a una UI premium coherente y “award-ready” sin caer en maquillaje visual. Primero se deben corregir defectos de lifecycle/estado que hoy degradan confiabilidad; después se aplica un sistema visual propio: “Ondas vivas globales”.
## Scope
### In Scope
- Hardening previo: listeners, streams, favoritos, reproductor, timer y errores.
- Sistema visual token-first: violeta profundo, magenta eléctrico, coral cálido, vidrio oscuro y glow controlado.
- Sistema de iconos propios para navegación, reproducción, favoritos, búsqueda, ajustes y estados.
- Mockup/refresh de Inicio, Buscar, Reproductor, Favoritos y Ajustes.
- Tests Strict TDD con `flutter test`; sin build.
### Out of Scope
- Reemplazar Provider/ChangeNotifier.
- Rediseñar backend/API de radios.
- Branding completo externo a la app.
- Release packaging o `flutter build`.
## Capabilities
### New Capabilities
- `ui-technical-hardening`: estabilidad previa a la capa premium.
- `premium-visual-system`: tokens, superficies, motion y composición visual.
- `premium-icon-system`: iconografía propia con estados outline/filled/active glow.
- `premium-screen-refresh`: aplicación coherente en las cinco pantallas principales.
### Modified Capabilities
- None; no existing OpenSpec source specs exist yet.
## Approach
Primero cerrar deuda crítica verificada: subscriptions cancelables, favorito global consistente, reorder persistente, reproductor desacoplado de snapshots viejos y errores observables. Luego introducir tokens visuales reutilizables e iconos propios, aplicándolos pantalla por pantalla con el Reproductor como referencia fuerte.
## Affected Areas
| Area | Impact | Description |
|---|---|---|
| `lib/app.dart` | Modified | Listener único/cancelable para errores. |
| `lib/widgets/visualizador_audio.dart` | Modified | Stream subscriptions seguras. |
| `lib/servicios/servicio_favoritos.dart` | Modified | Reordenado persistente completo. |
| `lib/pantallas/pantalla_favoritos.dart` | Modified | UX reorder consistente. |
| `lib/widgets/tarjeta_emisora.dart` | Modified | Favorito derivado del estado global. |
| `lib/pantallas/pantalla_reproductor.dart` | Modified | UI premium y estado de reproducción confiable. |
| `assets/icons/` | New | Iconos propios premium. |
| `test/` | Modified | Cobertura de hardening y UI behavior. |
## Risks
| Risk | Likelihood | Mitigation |
|---|---:|---|
| UI linda pero frágil | High | Hardening MUST precede visual rollout. |
| Iconos inconsistentes | Med | Definir geometría/estados antes de integrarlos. |
| Glow/motion excesivo | Med | Tokens, límites de contraste y reduced motion. |
## Rollback Plan
Revertir commits del cambio. Los assets nuevos y tokens pueden eliminarse sin migración de datos. Hardening debe revertirse solo si sus tests también se ajustan.
## Dependencies
- Flutter/Material existente.
- `flutter_test`.
- Assets locales bajo `assets/icons`.
## Success Criteria
- [ ] No listeners duplicados ni stream leaks verificados por tests.
- [ ] Favoritos y orden se mantienen consistentes.
- [ ] Reproductor usa estado actual confiable.
- [ ] Las cinco pantallas comparten lenguaje premium.
- [ ] Iconos propios reemplazan Material genérico en puntos clave.
- [ ] `flutter test` valida el cambio; no se ejecuta build.
@@ -0,0 +1,98 @@
# Spec: Premium award UI, icons, and code-review hardening
## Requirement: Hardening before premium UI
The system MUST correct lifecycle and state-integrity defects before premium visual work is considered complete.
### Scenario: hardening gate precedes visual rollout
- GIVEN premium UI work starts
- WHEN implementation tasks are planned
- THEN tests for listener lifecycle, favorite consistency, reorder persistence, playback state, timer cancellation, and error reporting MUST be written first
- AND premium UI tasks SHALL NOT be marked complete until those tests pass with `flutter test`
### Scenario: failures are observable
- GIVEN an async operation fails in playback, favorites, or loading
- WHEN the failure reaches app state
- THEN the system MUST expose a user-visible or test-observable error path
- AND it MUST NOT silently swallow the failure without state feedback
## Requirement: Lifecycle-safe subscriptions
The system MUST own and cancel every stream subscription/listener created by widgets or app shell.
### Scenario: app error stream is subscribed once
- GIVEN dependencies change more than once
- WHEN one error message is emitted
- THEN the app MUST show at most one SnackBar for that message
### Scenario: visualizer disposes cleanly
- GIVEN an audio visualizer or playback indicator is removed
- WHEN `estadoStream` emits later
- THEN no disposed widget SHALL call `setState`
- AND its subscription MUST be cancelled
## Requirement: Consistent favorites and ordering
The system MUST derive favorite UI from global state and persist complete ordering after reorder.
### Scenario: reorder persists full list order
- GIVEN favorites `[A, B, C]`
- WHEN the user moves `A` after `C`
- THEN persisted favorites MUST reload as `[B, C, A]`
- AND every item SHALL have a deterministic order
### Scenario: favorite state stays in sync
- GIVEN the same station appears in card and player surfaces
- WHEN favorite is toggled from either surface
- THEN all visible favorite icons MUST reflect the updated global state
## Requirement: Playback source of truth
The player MUST reconcile route input with `EstadoRadio` current playback state and MUST NOT keep acting on stale station snapshots.
### Scenario: active station changes while player is open
- GIVEN the player was opened for station `A`
- AND global playback changes to station `B`
- WHEN controls or favorite actions render
- THEN they MUST target the effective current station
- AND labels/artwork/actions SHALL remain consistent
## Requirement: Premium visual system
The system MUST define reusable visual tokens for “Ondas vivas globales”: deep violet, electric magenta, warm coral, dark glass surfaces, subtle neumorphism, controlled glow, radius, spacing, and motion.
### Scenario: screens consume tokens
- GIVEN a premium screen is rendered
- WHEN colors, surfaces, shadows, or motion are applied
- THEN they MUST come from shared tokens or theme extensions
- AND one-off hardcoded styling SHOULD be avoided
## Requirement: Premium icon system
The app MUST use custom PluriWave iconography for primary navigation, playback, favorite, search, settings, live/error/loading, and radio fallback states.
### Scenario: icon states are coherent
- GIVEN an icon supports inactive, selected, and active playback states
- WHEN each state is displayed
- THEN outline, filled, and glow variants MUST preserve the same rounded wave geometry
- AND tappable icons MUST keep accessible labels
## Requirement: Five-screen premium refresh
Inicio, Buscar, Reproductor, Favoritos, and Ajustes MUST share the premium language, with Reproductor as the strongest reference composition.
### Scenario: mockup scope is implemented coherently
- GIVEN the five primary screens are reviewed together
- WHEN navigation moves between them
- THEN tokens, icon style, hierarchy, empty states, and loading states SHALL feel like one product
- AND no primary screen SHOULD fall back to generic Material-only presentation
## Requirement: Accessibility and verification
The system MUST preserve accessibility while adding premium visuals.
### Scenario: premium visuals remain usable
- GIVEN large text, reduced motion, or high-contrast needs
- WHEN premium UI is rendered
- THEN content MUST remain readable and controls MUST remain reachable
- AND verification MUST use `flutter test`, never `flutter build`
@@ -0,0 +1,16 @@
change: premium-award-ui-icons-code-review
status: applying
mode: automatic
artifact_store: hybrid
created: 2026-05-20
updated: 2026-05-20
phases:
explore: completed
proposal: completed
specs: completed
design: completed
tasks: completed
apply: partial
verify: partial-static
archive: pending
next_recommended: run-format-and-full-tests
@@ -0,0 +1,95 @@
# Tasks: premium-award-ui-icons-code-review
## Fase 0 — Guardrails
- [x] No ejecutar build.
- [ ] No revertir cambios ajenos.
- [ ] Verificar estado inicial con `git status --short`.
- [ ] Ejecutar baseline de tests permitido: `flutter test`.
- [ ] Registrar fallos existentes antes de tocar código si los hubiera.
## Fase 1 — Tokens y tema
- [x] Crear tests para validar tokens premium mínimos.
- [x] Crear `lib/tema/pluriwave_tokens.dart`.
- [x] Crear `lib/tema/pluriwave_theme.dart`.
- [x] Crear `lib/tema/pluriwave_motion.dart`.
- [x] Reemplazar `ColorScheme.fromSeed` genérico por tema PluriWave tokenizado.
- [x] Mantener Material 3, pero con identidad propia.
- [x] Asegurar que widgets existentes sigan usando `Theme.of(context)`.
- [ ] Ejecutar `flutter test`.
## Fase 2 — Sistema de iconos
- [x] Definir contrato `PluriIcon`.
- [x] Definir enum/id de iconos core.
- [x] Implementar variantes `outline`, `filled`, `activeGlow`.
- [x] Agregar semantics labels obligatorios.
- [x] Reemplazar iconos de bottom navigation por `PluriIcon`.
- [ ] Agregar tests de variante activa, labels semánticos, tap targets y ausencia de regresión en navegación.
- [ ] Ejecutar `flutter test`.
## Fase 3 — Asset pipeline
- [x] Persistir prompts de mockup/icon sheet en documentación del cambio.
- [ ] Generar sheet con gutters chroma #00FF00.
- [ ] Implementar o usar pipeline: detectar gutters, crop, chroma-to-alpha, trim, center, pad, export, contact sheet, validate.
- [ ] Rechazar sheets con edge-touching, gutters contaminados o escala inconsistente.
- [ ] Exportar assets finales a `assets/icons/` o `assets/images/`.
- [ ] Actualizar `pubspec.yaml` solo si hace falta declarar subcarpetas nuevas.
- [ ] Ejecutar `flutter test`.
## Fase 4 — Componentes premium
- [x] Crear `PluriWaveScaffold`.
- [x] Crear `PluriGlassSurface`.
- [x] Crear fondo premium reutilizable con ondas/global aura.
- [x] Refactorizar `tarjeta_emisora` hacia tarjeta premium tokenizada.
- [x] Refactorizar `mini_reproductor` con glass pill y estado activo.
- [x] Refactorizar `visualizador_audio` con `RepaintBoundary`.
- [x] Refactorizar `ecualizador_widget` con tokens y estados accesibles.
- [ ] Agregar widget tests para componentes críticos.
- [ ] Ejecutar `flutter test`.
## Fase 5 — Pantallas
- [x] Actualizar `PantallaInicio` con hero global wave, tendencias y populares.
- [x] Actualizar `PantallaBuscar` con search premium, chips y stale-state guard.
- [x] Actualizar `PantallaFavoritos` con diseño reorderable.
- [x] Actualizar `PantallaReproductor` con player hero, controles premium y EQ entry point.
- [x] Actualizar `PantallaAjustes` con cards agrupadas e iconos propios.
- [ ] Agregar/actualizar tests de pantallas existentes.
- [ ] Ejecutar `flutter test`.
## Fase 6 — Hardening técnico
- [x] Corregir listener leak en `lib/app.dart`: mover escucha de `errorStream` fuera de `didChangeDependencies`, guardar `StreamSubscription`, cancelar en `dispose`.
- [ ] Agregar test que falle si se registran listeners duplicados.
- [ ] Implementar control de stale async state en búsquedas/cargas.
- [ ] Agregar tests de “última búsqueda gana”.
- [x] Implementar reorder persistente de favoritos.
- [ ] Agregar tests de reorder exitoso, rollback ante error, no duplicados por `uuid` y favorito actual correcto.
- [ ] Ejecutar `flutter test`.
## Fase 7 — Accesibilidad y polish
- [ ] Revisar contraste de tokens principales.
- [ ] Asegurar labels semánticos en controles principales.
- [ ] Asegurar tap targets mínimos 48x48.
- [ ] Soportar reduced motion / disable animations.
- [ ] Evitar que glow/color sea la única señal de estado.
- [ ] Agregar tests de semantics para nav, player, favoritos y EQ.
- [ ] Ejecutar `flutter test`.
## Fase 8 — Persistencia SDD híbrida
- [x] Persistir `proposal.md`, `spec.md`, `design.md`, `tasks.md` en openspec.
- [x] Guardar equivalentes en Engram con topic keys:
- `sdd/premium-award-ui-icons-code-review/proposal`
- `sdd/premium-award-ui-icons-code-review/spec`
- `sdd/premium-award-ui-icons-code-review/design`
- `sdd/premium-award-ui-icons-code-review/tasks`
- [x] Marcar siguiente fase recomendada: `sdd-verify`.
- [x] No ejecutar build.
+35
View File
@@ -171,6 +171,41 @@ void main() {
expect(estado.presetEcualizador, principal);
expect(audio.presetsAplicados.last, principal);
});
test('reordenar favoritos reindexa de forma determinística', () async {
final favoritos = FakeServicioFavoritos();
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A'));
await favoritos.agregar(emisoraDemo(uuid: 'b', nombre: 'B'));
await favoritos.agregar(emisoraDemo(uuid: 'c', nombre: 'C'));
await favoritos.reordenar('a', 2);
final lista = await favoritos.obtenerTodos();
expect(lista.map((e) => e.uuid).toList(), equals(['b', 'c', 'a']));
expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2]));
});
test('toggleFavorito refresca lista global y evita estado stale', () async {
final favoritos = FakeServicioFavoritos();
final emisora = emisoraDemo(uuid: 'fav-sync', nombre: 'Sync');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
await estado.toggleFavorito(emisora);
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isTrue);
await estado.toggleFavorito(emisora);
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
});
});
}
+26 -8
View File
@@ -55,35 +55,53 @@ class FakeServicioAudio extends ServicioAudio {
}
class FakeServicioFavoritos extends ServicioFavoritos {
final Map<String, Emisora> _favoritos = {};
final List<Emisora> _favoritos = [];
int toggleCalls = 0;
@override
Future<List<Emisora>> obtenerTodos() async => _favoritos.values.toList();
Future<List<Emisora>> obtenerTodos() async =>
_favoritos.map((e) => e.copyWith()).toList();
@override
Future<void> agregar(Emisora emisora) async {
_favoritos[emisora.uuid] = emisora;
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
}
@override
Future<void> eliminar(String uuid) async {
_favoritos.remove(uuid);
_favoritos.removeWhere((e) => e.uuid == uuid);
for (var i = 0; i < _favoritos.length; i++) {
_favoritos[i] = _favoritos[i].copyWith(orden: i);
}
}
@override
Future<bool> esFavorito(String uuid) async => _favoritos.containsKey(uuid);
Future<bool> esFavorito(String uuid) async =>
_favoritos.any((e) => e.uuid == uuid);
@override
Future<bool> toggleFavorito(Emisora emisora) async {
toggleCalls += 1;
if (_favoritos.containsKey(emisora.uuid)) {
_favoritos.remove(emisora.uuid);
if (_favoritos.any((e) => e.uuid == emisora.uuid)) {
await eliminar(emisora.uuid);
return false;
}
_favoritos[emisora.uuid] = emisora;
await agregar(emisora);
return true;
}
@override
Future<void> reordenar(String uuid, int nuevoOrden) async {
final oldIndex = _favoritos.indexWhere((e) => e.uuid == uuid);
if (oldIndex == -1 || _favoritos.isEmpty) return;
final targetIndex = nuevoOrden.clamp(0, _favoritos.length - 1);
final moved = _favoritos.removeAt(oldIndex);
_favoritos.insert(targetIndex, moved);
for (var i = 0; i < _favoritos.length; i++) {
_favoritos[i] = _favoritos[i].copyWith(orden: i);
}
}
}
class FakeServicioRadio extends ServicioRadio {
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_icon.dart';
import 'package:pluriwave/widgets/pluri_wave_scaffold.dart';
void main() {
test('PluriWaveTokens.dark mantiene valores base esperados', () {
expect(PluriWaveTokens.dark.deepViolet, const Color(0xFF24123D));
expect(PluriWaveTokens.dark.radiusMd, 16);
expect(PluriWaveTokens.dark.spacingMd, 16);
});
testWidgets('PluriIcon expone semantics label', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: PluriWaveTheme.dark(),
home: const Scaffold(
body: PluriIcon(
glyph: PluriIconGlyph.search,
variant: PluriIconVariant.activeGlow,
semanticLabel: 'Buscar emisoras',
),
),
),
);
expect(find.bySemanticsLabel('Buscar emisoras'), findsOneWidget);
});
testWidgets('PluriGlassSurface y PluriWaveScaffold se instancian', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: PluriWaveTheme.dark(),
home: const PluriWaveScaffold(
body: Center(
child: PluriGlassSurface(
child: Text('ok'),
),
),
),
),
);
expect(find.byType(PluriWaveScaffold), findsOneWidget);
expect(find.byType(PluriGlassSurface), findsOneWidget);
expect(find.text('ok'), findsOneWidget);
});
}
@@ -0,0 +1,48 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
void main() {
group('VisualizadorAudio lifecycle', () {
testWidgets('ignora eventos de estado después de dispose', (tester) async {
final controller = StreamController<EstadoReproduccion>.broadcast();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: VisualizadorAudio(estadoStream: controller.stream),
),
),
);
await tester.pumpWidget(const SizedBox.shrink());
controller.add(EstadoReproduccion.reproduciendo);
await tester.pump();
expect(tester.takeException(), isNull);
await controller.close();
});
testWidgets('ignora eventos de estado después de dispose en indicador', (tester) async {
final controller = StreamController<EstadoReproduccion>.broadcast();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: IndicadorReproduccion(estadoStream: controller.stream),
),
),
);
await tester.pumpWidget(const SizedBox.shrink());
controller.add(EstadoReproduccion.reproduciendo);
await tester.pump();
expect(tester.takeException(), isNull);
await controller.close();
});
});
}