611 lines
20 KiB
Dart
611 lines
20 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:package_info_plus/package_info_plus.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: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_premium_widgets.dart';
|
|
|
|
class PantallaAjustes extends StatelessWidget {
|
|
const PantallaAjustes({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
|
|
children: const [
|
|
PluriScreenHeader(
|
|
title: 'Ajustes',
|
|
subtitle:
|
|
'Control fino de sonido, copias de seguridad y emisoras personalizadas.',
|
|
glyph: PluriIconGlyph.settings,
|
|
trailing: PluriStatusPill(
|
|
icon: Icons.security_rounded,
|
|
label: 'Seguro',
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
|
child: _AjustesContent(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AjustesContent extends StatelessWidget {
|
|
const _AjustesContent();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: const [
|
|
_SeccionEcualizador(),
|
|
SizedBox(height: 12),
|
|
_SeccionGrabaciones(),
|
|
SizedBox(height: 12),
|
|
_SeccionEmisoras(),
|
|
SizedBox(height: 12),
|
|
_SeccionBackup(),
|
|
SizedBox(height: 12),
|
|
_SeccionInfo(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionGrabaciones extends StatelessWidget {
|
|
const _SeccionGrabaciones();
|
|
|
|
Future<void> _seleccionarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final ruta = await FilePicker.platform.getDirectoryPath(
|
|
dialogTitle: 'Selecciona la carpeta de grabaciones',
|
|
);
|
|
if (ruta == null) return;
|
|
try {
|
|
await estado.cambiarDirectorioGrabacion(ruta);
|
|
messenger.showSnackBar(
|
|
const SnackBar(content: Text('Ruta de grabación actualizada')),
|
|
);
|
|
} catch (e) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text('No se pudo guardar la ruta: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _restaurarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
await estado.restaurarDirectorioGrabacion();
|
|
messenger.showSnackBar(
|
|
const SnackBar(content: Text('Se usará la carpeta interna por defecto')),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.radio_button_checked),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
'Grabaciones',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
FutureBuilder<String>(
|
|
future: estado.directorioGrabacionEfectivo(),
|
|
builder:
|
|
(ctx, snap) => ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.folder_outlined),
|
|
title: const Text('Carpeta de grabación'),
|
|
subtitle: Text(
|
|
snap.data ?? 'Calculando ruta...',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
onTap: () => _seleccionarRuta(context),
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.folder_open_rounded),
|
|
label: const Text('Cambiar ruta'),
|
|
onPressed: () => _seleccionarRuta(context),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.filledTonal(
|
|
tooltip: 'Usar ruta por defecto',
|
|
icon: const Icon(Icons.restore_rounded),
|
|
onPressed: () => _restaurarRuta(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'La radio se guarda desde el stream original, sin recomprimir.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionEcualizador extends StatelessWidget {
|
|
const _SeccionEcualizador();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<EstadoRadio>(
|
|
builder: (ctx, estado, _) {
|
|
final disponible = estado.ecualizadorDisponible;
|
|
final emisoraActual = estado.emisoraActual;
|
|
final mostrarModoPorEmisora =
|
|
emisoraActual != null && estado.emisoraActualEsFavorita;
|
|
final usandoEqPropio = estado.emisoraActualTienePresetPropio;
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.equalizer),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
'Ecualizador',
|
|
style: Theme.of(ctx).textTheme.titleMedium,
|
|
),
|
|
const Spacer(),
|
|
Chip(
|
|
label: Text(
|
|
estado.ecualizadorActivo ? 'Activo' : 'Desactivado',
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Activar ecualizador'),
|
|
subtitle: Text(
|
|
disponible
|
|
? 'Los cambios se aplican en tiempo real a la emisora actual.'
|
|
: 'Se guardan los cambios y se aplicarán cuando Android habilite el efecto.',
|
|
),
|
|
value: estado.ecualizadorActivo,
|
|
onChanged: estado.cambiarEcualizadorActivo,
|
|
),
|
|
if (mostrarModoPorEmisora) ...[
|
|
const SizedBox(height: 8),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Usar EQ propio para esta favorita'),
|
|
subtitle: Text(
|
|
usandoEqPropio
|
|
? 'Activo para ${emisoraActual.nombre}'
|
|
: 'Usando EQ principal para ${emisoraActual.nombre}',
|
|
),
|
|
value: usandoEqPropio,
|
|
onChanged:
|
|
(usarPropio) =>
|
|
estado.cambiarModoEcualizadorEmisoraActual(
|
|
usarPropio: usarPropio,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
PresetsEcualizadorWidget(
|
|
presetActual: estado.presetEcualizador,
|
|
onSeleccionar: (p) => estado.cambiarPresetEcualizador(p),
|
|
),
|
|
const SizedBox(height: 12),
|
|
EcualizadorWidget(
|
|
preset: estado.presetEcualizador,
|
|
onCambio: (p) => estado.cambiarPresetEcualizador(p),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionEmisoras extends StatelessWidget {
|
|
const _SeccionEmisoras();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
final custom = estado.emisorasCustom;
|
|
|
|
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,
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Añadir'),
|
|
onPressed: () => _mostrarFormularioAnadir(context),
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (ctx) => const _FormularioEmisora(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FormularioEmisora extends StatefulWidget {
|
|
const _FormularioEmisora();
|
|
|
|
@override
|
|
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
|
|
}
|
|
|
|
class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nombreCtrl = TextEditingController();
|
|
final _urlCtrl = TextEditingController();
|
|
final _paisCtrl = TextEditingController();
|
|
bool _guardando = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreCtrl.dispose();
|
|
_urlCtrl.dispose();
|
|
_paisCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _guardar() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
setState(() => _guardando = true);
|
|
|
|
final emisora = Emisora(
|
|
uuid: const Uuid().v4(),
|
|
nombre: _nombreCtrl.text.trim(),
|
|
url: _urlCtrl.text.trim(),
|
|
pais: _paisCtrl.text.trim().isEmpty ? null : _paisCtrl.text.trim(),
|
|
);
|
|
|
|
await context.read<EstadoRadio>().agregarEmitoraCustom(emisora);
|
|
if (mounted) Navigator.pop(context);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottom),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
'Añadir emisora',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _nombreCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Nombre *',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator:
|
|
(v) =>
|
|
v == null || v.trim().isEmpty
|
|
? 'Campo obligatorio'
|
|
: null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _urlCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'URL del stream *',
|
|
hintText: 'http://stream.ejemplo.com:8000/radio',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.url,
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) return 'Campo obligatorio';
|
|
final uri = Uri.tryParse(v.trim());
|
|
if (uri == null || !uri.hasScheme) return 'URL no válida';
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _paisCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'País (opcional)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton(
|
|
onPressed: _guardando ? null : _guardar,
|
|
child:
|
|
_guardando
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Guardar emisora'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionBackup extends StatelessWidget {
|
|
const _SeccionBackup();
|
|
|
|
Future<void> _exportar(BuildContext context) async {
|
|
try {
|
|
final estado = context.read<EstadoRadio>();
|
|
final config = await estado.exportarConfig();
|
|
final json = const JsonEncoder.withIndent(' ').convert(config);
|
|
|
|
final dir = await getTemporaryDirectory();
|
|
final file = File('${dir.path}/pluriwave-backup.json');
|
|
await file.writeAsString(json);
|
|
|
|
await Share.shareXFiles(
|
|
[XFile(file.path)],
|
|
subject: 'PluriWave — copia de seguridad',
|
|
text:
|
|
'Configuración de PluriWave exportada el ${DateTime.now().toLocal()}',
|
|
);
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
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'],
|
|
);
|
|
if (result == null || result.files.single.path == null) return;
|
|
|
|
final file = File(result.files.single.path!);
|
|
final json =
|
|
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
|
|
|
if (context.mounted) {
|
|
final confirmar = await showDialog<bool>(
|
|
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?',
|
|
),
|
|
actions: [
|
|
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) {
|
|
final estado = context.read<EstadoRadio>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
await estado.importarConfig(json);
|
|
messenger.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')));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeccionInfo extends StatelessWidget {
|
|
const _SeccionInfo();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<EstadoRadio>(
|
|
builder:
|
|
(ctx, estado, _) => PluriGlassSurface(
|
|
child: Column(
|
|
children: [
|
|
FutureBuilder<PackageInfo>(
|
|
future: PackageInfo.fromPlatform(),
|
|
builder: (ctx, snap) {
|
|
final version =
|
|
snap.hasData
|
|
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
|
: 'Cargando versión...';
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const PluriIcon(
|
|
glyph: PluriIconGlyph.settings,
|
|
variant: PluriIconVariant.filled,
|
|
),
|
|
title: const Text('PluriWave'),
|
|
subtitle: Text('$version - Radio mundial'),
|
|
);
|
|
},
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|