feat(ui): add premium PluriWave redesign
This commit is contained in:
+40
-35
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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]],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_glass_surface.dart';
|
||||
|
||||
/// Widget de ecualizador con 5 sliders verticales.
|
||||
/// Basado en JaviHogar EcualizadorWidget, adaptado a Material You.
|
||||
class EcualizadorWidget extends StatefulWidget {
|
||||
final PresetEcualizador preset;
|
||||
final void Function(PresetEcualizador) onCambio;
|
||||
@@ -39,60 +40,76 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Ecualizador', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (int i = 0; i < 5; i++)
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: RotatedBox(
|
||||
quarterTurns: 3,
|
||||
child: Slider(
|
||||
value: _bandas[i],
|
||||
min: -12.0,
|
||||
max: 12.0,
|
||||
divisions: 24,
|
||||
onChanged: (v) => _actualizarBanda(i, v),
|
||||
final tokens = context.pluriTokens;
|
||||
|
||||
return PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Ecualizador', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
Chip(
|
||||
label: Text(widget.preset.nombre, style: theme.textTheme.labelMedium),
|
||||
backgroundColor: theme.colorScheme.secondaryContainer.withValues(alpha: 0.75),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (int i = 0; i < 5; i++)
|
||||
Expanded(
|
||||
child: Card(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 152,
|
||||
child: Semantics(
|
||||
slider: true,
|
||||
label: 'Banda ${_etiquetas[i]}',
|
||||
value: '${_bandas[i].toStringAsFixed(1)} decibelios',
|
||||
child: RotatedBox(
|
||||
quarterTurns: 3,
|
||||
child: Slider(
|
||||
value: _bandas[i],
|
||||
min: -12.0,
|
||||
max: 12.0,
|
||||
divisions: 24,
|
||||
onChanged: (v) => _actualizarBanda(i, v),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_bandas[i].toStringAsFixed(1)}dB',
|
||||
style: theme.textTheme.labelSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
_etiquetas[i],
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
Text('${_bandas[i].toStringAsFixed(1)}dB', style: theme.textTheme.labelSmall),
|
||||
Text(
|
||||
_etiquetas[i],
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chips de presets predefinidos.
|
||||
class PresetsEcualizadorWidget extends StatelessWidget {
|
||||
final PresetEcualizador presetActual;
|
||||
final void Function(PresetEcualizador) onSeleccionar;
|
||||
@@ -105,13 +122,18 @@ class PresetsEcualizadorWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
runSpacing: 6,
|
||||
children: PresetEcualizador.presets.map((p) {
|
||||
final selected = p.nombre == presetActual.nombre;
|
||||
return ChoiceChip(
|
||||
label: Text(p.nombre),
|
||||
selected: p.nombre == presetActual.nombre,
|
||||
selected: selected,
|
||||
showCheckmark: false,
|
||||
selectedColor: theme.colorScheme.primaryContainer,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.32),
|
||||
onSelected: (_) => onSeleccionar(p),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../pantallas/pantalla_reproductor.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_glass_surface.dart';
|
||||
import 'pluri_icon.dart';
|
||||
import 'visualizador_audio.dart';
|
||||
|
||||
/// Barra inferior persistente con controles básicos de reproducción.
|
||||
/// Barra inferior persistente con controles básicos de reproducción.
|
||||
/// Toca la barra para abrir PantallaReproductor completa.
|
||||
class MiniReproductor extends StatelessWidget {
|
||||
const MiniReproductor({super.key});
|
||||
@@ -17,107 +21,136 @@ class MiniReproductor extends StatelessWidget {
|
||||
|
||||
if (emisora == null) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final t = context.pluriTokens;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => PantallaReproductor.abrir(context, emisora),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant, width: 0.5)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Indicador de reproducción (mini visualizador)
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: IndicadorReproduccion(
|
||||
estadoStream: estado.estadoStream,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 20,
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingSm),
|
||||
child: PluriGlassSurface(
|
||||
padding: EdgeInsets.symmetric(horizontal: t.spacingSm, vertical: t.spacingXs),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Abrir reproductor de ${emisora.nombre}',
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => PantallaReproductor.abrir(context, emisora),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: t.spacingXs,
|
||||
vertical: t.spacingXs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: IndicadorReproduccion(
|
||||
estadoStream: estado.estadoStream,
|
||||
color: t.electricMagenta,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: t.spacingSm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
emisora.nombre,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final s = snapshot.data ?? EstadoReproduccion.detenido;
|
||||
final activo = s == EstadoReproduccion.reproduciendo;
|
||||
return Text(
|
||||
_labelEstado(s),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: activo
|
||||
? t.warmCoral
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
fontWeight: activo ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PluriIcon(
|
||||
glyph: PluriIconGlyph.player,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
size: 18,
|
||||
semanticLabel: 'Reproductor',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Nombre y estado
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
emisora.nombre,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final s = snapshot.data ?? EstadoReproduccion.detenido;
|
||||
if (s == EstadoReproduccion.cargando) {
|
||||
return const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final s = snapshot.data ??
|
||||
EstadoReproduccion.detenido;
|
||||
return Text(
|
||||
_labelEstado(s),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Botón play/pause
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final s =
|
||||
snapshot.data ?? EstadoReproduccion.detenido;
|
||||
if (s == EstadoReproduccion.cargando) {
|
||||
return const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
// En estado error: mostrar icono de reintento
|
||||
if (s == EstadoReproduccion.error) {
|
||||
final emisora = estado.emisoraActual;
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Reintentar',
|
||||
onPressed: emisora != null
|
||||
? () => estado.reproducir(emisora)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (s == EstadoReproduccion.error) {
|
||||
final emisoraActual = estado.emisoraActual;
|
||||
return IconButton(
|
||||
tooltip: 'Reintentar',
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: emisoraActual != null ? () => estado.reproducir(emisoraActual) : null,
|
||||
constraints: const BoxConstraints.tightFor(width: 48, height: 48),
|
||||
);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: s == EstadoReproduccion.reproduciendo ? 'Pausar' : 'Reproducir',
|
||||
child: IconButton(
|
||||
tooltip: s == EstadoReproduccion.reproduciendo ? 'Pausar' : 'Reproducir',
|
||||
icon: Icon(
|
||||
s == EstadoReproduccion.reproduciendo
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
? Icons.pause_circle_filled_rounded
|
||||
: Icons.play_circle_fill_rounded,
|
||||
color: t.electricMagenta,
|
||||
),
|
||||
onPressed: () {
|
||||
// Evitar que el tap en el botón abra el reproductor
|
||||
estado.togglePlay();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: estado.togglePlay,
|
||||
constraints: const BoxConstraints.tightFor(width: 48, height: 48),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -127,9 +160,9 @@ class MiniReproductor extends StatelessWidget {
|
||||
String _labelEstado(EstadoReproduccion estado) {
|
||||
return switch (estado) {
|
||||
EstadoReproduccion.cargando => 'Conectando...',
|
||||
EstadoReproduccion.reproduciendo => 'En directo ●',
|
||||
EstadoReproduccion.reproduciendo => 'En directo',
|
||||
EstadoReproduccion.pausado => 'Pausado',
|
||||
EstadoReproduccion.error => 'Error de conexión',
|
||||
EstadoReproduccion.error => 'Error de conexión',
|
||||
EstadoReproduccion.detenido => 'Detenido',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
|
||||
class PluriGlassSurface extends StatelessWidget {
|
||||
const PluriGlassSurface({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.borderRadius,
|
||||
this.blurSigma = 14,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final BorderRadius? borderRadius;
|
||||
final double blurSigma;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.pluriTokens;
|
||||
final radius = borderRadius ?? BorderRadius.circular(t.radiusMd);
|
||||
return RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: t.glassSurface,
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: t.glassBorder),
|
||||
),
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
|
||||
enum PluriIconGlyph { home, search, favorites, player, settings }
|
||||
|
||||
enum PluriIconVariant { outline, filled, activeGlow }
|
||||
|
||||
class PluriIcon extends StatelessWidget {
|
||||
const PluriIcon({
|
||||
super.key,
|
||||
required this.glyph,
|
||||
this.variant = PluriIconVariant.outline,
|
||||
this.size = 24,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final PluriIconGlyph glyph;
|
||||
final PluriIconVariant variant;
|
||||
final double size;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.pluriTokens;
|
||||
final icon = Icon(
|
||||
_resolveData(),
|
||||
size: size,
|
||||
color: _resolveColor(context, tokens),
|
||||
);
|
||||
final child = variant == PluriIconVariant.activeGlow
|
||||
? Container(
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: tokens.glowColor,
|
||||
blurRadius: 14,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: icon,
|
||||
)
|
||||
: icon;
|
||||
|
||||
return Semantics(
|
||||
label: semanticLabel ?? _fallbackLabel(glyph),
|
||||
image: true,
|
||||
child: ExcludeSemantics(child: child),
|
||||
);
|
||||
}
|
||||
|
||||
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
|
||||
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
|
||||
if (variant == PluriIconVariant.filled) return Theme.of(context).colorScheme.onSurface;
|
||||
return Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.78);
|
||||
}
|
||||
|
||||
IconData _resolveData() {
|
||||
return switch ((glyph, variant)) {
|
||||
(PluriIconGlyph.home, PluriIconVariant.outline) => Icons.home_outlined,
|
||||
(PluriIconGlyph.home, _) => Icons.home_rounded,
|
||||
(PluriIconGlyph.search, PluriIconVariant.outline) => Icons.search_outlined,
|
||||
(PluriIconGlyph.search, _) => Icons.search_rounded,
|
||||
(PluriIconGlyph.favorites, PluriIconVariant.outline) => Icons.favorite_border_rounded,
|
||||
(PluriIconGlyph.favorites, _) => Icons.favorite_rounded,
|
||||
(PluriIconGlyph.player, PluriIconVariant.outline) => Icons.play_circle_outline_rounded,
|
||||
(PluriIconGlyph.player, _) => Icons.play_circle_rounded,
|
||||
(PluriIconGlyph.settings, PluriIconVariant.outline) => Icons.settings_outlined,
|
||||
(PluriIconGlyph.settings, _) => Icons.settings_rounded,
|
||||
};
|
||||
}
|
||||
|
||||
String _fallbackLabel(PluriIconGlyph glyph) {
|
||||
return switch (glyph) {
|
||||
PluriIconGlyph.home => 'Inicio',
|
||||
PluriIconGlyph.search => 'Buscar',
|
||||
PluriIconGlyph.favorites => 'Favoritos',
|
||||
PluriIconGlyph.player => 'Reproductor',
|
||||
PluriIconGlyph.settings => 'Ajustes',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
|
||||
class PluriWaveScaffold extends StatelessWidget {
|
||||
const PluriWaveScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
this.appBar,
|
||||
this.bottomNavigationBar,
|
||||
this.floatingActionButton,
|
||||
});
|
||||
|
||||
final PreferredSizeWidget? appBar;
|
||||
final Widget body;
|
||||
final Widget? bottomNavigationBar;
|
||||
final Widget? floatingActionButton;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.pluriTokens;
|
||||
return Scaffold(
|
||||
backgroundColor: t.deepViolet,
|
||||
appBar: appBar,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
floatingActionButton: floatingActionButton,
|
||||
body: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: const Alignment(-0.75, -0.9),
|
||||
radius: 1.25,
|
||||
colors: [
|
||||
t.electricMagenta.withValues(alpha: 0.22),
|
||||
t.deepViolet,
|
||||
const Color(0xFF10091B),
|
||||
],
|
||||
stops: const [0.0, 0.42, 1.0],
|
||||
),
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'pluri_glass_surface.dart';
|
||||
import 'pluri_icon.dart';
|
||||
|
||||
/// Tarjeta compacta para mostrar una emisora en listas y grids.
|
||||
/// Incluye botón de favorito visible en ambos modos.
|
||||
/// Incluye botón de favorito visible en ambos modos.
|
||||
class TarjetaEmisora extends StatefulWidget {
|
||||
final Emisora emisora;
|
||||
final VoidCallback? onTap;
|
||||
@@ -24,31 +28,18 @@ class TarjetaEmisora extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
bool _esFavorito = false;
|
||||
bool _toggling = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkFavorito();
|
||||
}
|
||||
|
||||
Future<void> _checkFavorito() async {
|
||||
final fav = await context.read<EstadoRadio>().esFavorito(widget.emisora.uuid);
|
||||
if (mounted) setState(() => _esFavorito = fav);
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
if (_toggling) return;
|
||||
_toggling = true;
|
||||
setState(() => _toggling = true);
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final esFav = await estado.toggleFavorito(widget.emisora);
|
||||
if (mounted) setState(() => _esFavorito = esFav);
|
||||
_toggling = false;
|
||||
if (mounted) setState(() => _toggling = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(esFav
|
||||
? '${widget.emisora.nombre} añadida a favoritos'
|
||||
? '${widget.emisora.nombre} añadida a favoritos'
|
||||
: '${widget.emisora.nombre} eliminada de favoritos'),
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
@@ -57,19 +48,26 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: widget.esCompacta
|
||||
? _buildCompacta(theme)
|
||||
: _buildCompleta(theme),
|
||||
final t = context.pluriTokens;
|
||||
return Semantics(
|
||||
button: widget.onTap != null,
|
||||
label: 'Emisora ${widget.emisora.nombre}',
|
||||
child: PluriGlassSurface(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: BorderRadius.circular(widget.esCompacta ? t.radiusMd : t.radiusLg),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: widget.esCompacta ? _buildCompacta() : _buildCompleta(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompleta(ThemeData theme) {
|
||||
Widget _buildCompleta() {
|
||||
final t = context.pluriTokens;
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
@@ -77,96 +75,148 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _logo(theme, 60),
|
||||
child: _logo(60),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingMd),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.emisora.nombre,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.emisora.pais != null)
|
||||
Text(
|
||||
widget.emisora.pais!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: t.spacingXs),
|
||||
child: Text(
|
||||
widget.emisora.pais!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.72),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Botón favorito superpuesto (esquina superior derecha)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: _botonFavorito(theme, mini: true),
|
||||
top: t.spacingSm,
|
||||
right: t.spacingSm,
|
||||
child: _botonFavorito(mini: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompacta(ThemeData theme) {
|
||||
return ListTile(
|
||||
leading: SizedBox(width: 48, height: 48, child: _logo(theme, 24)),
|
||||
title: Text(
|
||||
widget.emisora.nombre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Widget _buildCompacta() {
|
||||
final t = context.pluriTokens;
|
||||
final subtitulo = [widget.emisora.pais, widget.emisora.idioma]
|
||||
.where((s) => s != null && s.isNotEmpty)
|
||||
.join(' · ');
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: t.spacingSm, vertical: t.spacingXs),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(t.radiusSm),
|
||||
child: SizedBox(width: 48, height: 48, child: _logo(24)),
|
||||
),
|
||||
SizedBox(width: t.spacingSm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.emisora.nombre,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitulo.isNotEmpty)
|
||||
Text(
|
||||
subtitulo,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_botonFavorito(mini: false),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
[widget.emisora.pais, widget.emisora.idioma]
|
||||
.where((s) => s != null && s.isNotEmpty)
|
||||
.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: _botonFavorito(theme, mini: false),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _botonFavorito(ThemeData theme, {required bool mini}) {
|
||||
return Material(
|
||||
color: mini
|
||||
? theme.colorScheme.surface.withValues(alpha: 0.8)
|
||||
: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _toggle,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(mini ? 6 : 4),
|
||||
child: Icon(
|
||||
_esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
|
||||
color: _esFavorito ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
|
||||
size: mini ? 18 : 22,
|
||||
Widget _botonFavorito({required bool mini}) {
|
||||
final t = context.pluriTokens;
|
||||
final esFavorito = context.select<EstadoRadio, bool>(
|
||||
(estado) => estado.listaFavoritos.any((e) => e.uuid == widget.emisora.uuid),
|
||||
);
|
||||
|
||||
final icono = mini
|
||||
? Icon(
|
||||
esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
|
||||
color: esFavorito ? t.warmCoral : Colors.white.withValues(alpha: 0.82),
|
||||
size: 18,
|
||||
)
|
||||
: PluriIcon(
|
||||
glyph: PluriIconGlyph.favorites,
|
||||
variant: esFavorito ? PluriIconVariant.activeGlow : PluriIconVariant.outline,
|
||||
size: 20,
|
||||
semanticLabel: esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos',
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
toggled: esFavorito,
|
||||
label: esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos',
|
||||
child: Material(
|
||||
color: mini ? t.glassSurface : Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _toggling ? null : _toggle,
|
||||
child: SizedBox(
|
||||
width: mini ? 36 : 44,
|
||||
height: mini ? 36 : 44,
|
||||
child: Center(child: icono),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _logo(ThemeData theme, double iconSize) {
|
||||
Widget _logo(double iconSize) {
|
||||
if (widget.emisora.favicon != null && widget.emisora.favicon!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme, iconSize),
|
||||
placeholder: (_, __) => _shimmer(),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(iconSize),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme, iconSize);
|
||||
return _iconoFallback(iconSize);
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) {
|
||||
Widget _shimmer() {
|
||||
final theme = Theme.of(context);
|
||||
return Shimmer.fromColors(
|
||||
baseColor: theme.colorScheme.surfaceContainerHighest,
|
||||
highlightColor: theme.colorScheme.surface,
|
||||
@@ -174,10 +224,24 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _iconoFallback(ThemeData theme, double size) {
|
||||
return Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(Icons.radio, size: size, color: theme.colorScheme.onPrimaryContainer),
|
||||
Widget _iconoFallback(double size) {
|
||||
final t = context.pluriTokens;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [t.deepViolet, t.electricMagenta.withValues(alpha: 0.8)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: PluriIcon(
|
||||
glyph: PluriIconGlyph.player,
|
||||
variant: PluriIconVariant.filled,
|
||||
size: size,
|
||||
semanticLabel: 'Icono de emisora',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
@@ -49,6 +50,7 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
||||
late List<_BarraState> _barras;
|
||||
final _random = Random();
|
||||
bool _activo = false;
|
||||
StreamSubscription<EstadoReproduccion>? _estadoSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -68,13 +70,14 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
||||
duration: const Duration(seconds: 1),
|
||||
)..addListener(_actualizar);
|
||||
|
||||
widget.estadoStream.listen(_onEstado);
|
||||
_estadoSubscription = widget.estadoStream.listen(_onEstado);
|
||||
}
|
||||
|
||||
void _onEstado(EstadoReproduccion estado) {
|
||||
final nuevoActivo = estado == EstadoReproduccion.reproduciendo ||
|
||||
estado == EstadoReproduccion.cargando;
|
||||
if (nuevoActivo == _activo) return;
|
||||
if (!mounted) return;
|
||||
setState(() => _activo = nuevoActivo);
|
||||
if (nuevoActivo) {
|
||||
_controller.repeat();
|
||||
@@ -91,6 +94,7 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_estadoSubscription?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -110,10 +114,11 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
||||
final espaciado = totalAncho / widget.barras;
|
||||
final anchoBar = (espaciado * 0.55).clamp(2.0, 8.0);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(widget.barras, (i) {
|
||||
return RepaintBoundary(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(widget.barras, (i) {
|
||||
final b = _barras[i];
|
||||
final double altura;
|
||||
|
||||
@@ -131,21 +136,22 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
||||
altura = b.alturaActual.clamp(2.0, widget.altura * 0.05);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: (espaciado - anchoBar) / 2),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
width: anchoBar,
|
||||
height: altura.clamp(2.0, widget.altura),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(
|
||||
alpha: _activo ? 0.7 + (altura / widget.altura) * 0.3 : 0.3,
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: (espaciado - anchoBar) / 2),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
width: anchoBar,
|
||||
height: altura.clamp(2.0, widget.altura),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(
|
||||
alpha: _activo ? 0.7 + (altura / widget.altura) * 0.3 : 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(anchoBar / 2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(anchoBar / 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -190,15 +196,17 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
bool _reproduciendo = false;
|
||||
StreamSubscription<EstadoReproduccion>? _estadoSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600))
|
||||
..addListener(() => setState(() {}));
|
||||
widget.estadoStream.listen((s) {
|
||||
_estadoSubscription = widget.estadoStream.listen((s) {
|
||||
final rep = s == EstadoReproduccion.reproduciendo;
|
||||
if (rep == _reproduciendo) return;
|
||||
if (!mounted) return;
|
||||
setState(() => _reproduciendo = rep);
|
||||
rep ? _ctrl.repeat(reverse: true) : _ctrl.stop();
|
||||
});
|
||||
@@ -206,6 +214,7 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_estadoSubscription?.cancel();
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user