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