feat(ui): add premium PluriWave redesign
Build & Deploy Pluriwave / Análisis de código (push) Failing after 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped

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