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_idioma.dart'; import '../estado/estado_radio.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_onboarding_dialog.dart'; import '../widgets/pluri_premium_widgets.dart'; class PantallaAjustes extends StatelessWidget { const PantallaAjustes({super.key}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return ListView( padding: PluriLayout.pageListPadding, children: [ PluriScreenHeader( title: l10n.settingsTitle, subtitle: l10n.settingsSubtitle, glyph: PluriIconGlyph.settings, trailing: PluriStatusPill( icon: Icons.security_rounded, label: l10n.settingsSafeStatus, ), ), const Padding( padding: PluriLayout.pageContentPadding, 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), _SeccionTimerSueno(), SizedBox(height: 12), _SeccionIdioma(), SizedBox(height: 12), _SeccionOrdenListas(), SizedBox(height: 12), _SeccionGruposFavoritos(), SizedBox(height: 12), _SeccionEmisoraPreferida(), SizedBox(height: 12), _SeccionEmisoras(), SizedBox(height: 12), _SeccionBackup(), SizedBox(height: 12), _SeccionInfo(), ], ); } } class _SeccionGrabaciones extends StatelessWidget { const _SeccionGrabaciones(); Future _seleccionarRuta(BuildContext context) async { final estado = context.read(); final messenger = ScaffoldMessenger.of(context); final l10n = AppLocalizations.of(context); final ruta = await FilePicker.platform.getDirectoryPath( dialogTitle: l10n.recordingsFolderDialogTitle, ); if (ruta == null) return; try { await estado.cambiarDirectorioGrabacion(ruta); if (!context.mounted) return; messenger.showSnackBar( SnackBar(content: Text(l10n.recordingsPathUpdated)), ); } catch (e) { if (!context.mounted) return; messenger.showSnackBar( SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))), ); } } Future _restaurarRuta(BuildContext context) async { final estado = context.read(); final messenger = ScaffoldMessenger.of(context); final l10n = AppLocalizations.of(context); await estado.restaurarDirectorioGrabacion(); if (!context.mounted) return; messenger.showSnackBar( SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)), ); } Future _abrirCarpeta(BuildContext context) async { final estado = context.read(); final messenger = ScaffoldMessenger.of(context); final l10n = AppLocalizations.of(context); try { final abierto = await estado.abrirDirectorioGrabacion(); if (!context.mounted) return; if (!abierto) { messenger.showSnackBar( SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))), ); } } catch (e) { if (!context.mounted) return; messenger.showSnackBar( SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))), ); } } Future _editarTamanoMaximo(BuildContext context) async { final estado = context.read(); final l10n = AppLocalizations.of(context); final actualMb = _bytesAMegabytes(estado.maxBytesGrabacion); final controller = TextEditingController(text: actualMb.toString()); final nuevoMb = await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (ctx) { final bottom = MediaQuery.viewInsetsOf(ctx).bottom; return Padding( padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.recordingsMaxSizeDialogTitle, style: Theme.of(ctx).textTheme.titleLarge, ), const SizedBox(height: 16), TextField( controller: controller, autofocus: true, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: l10n.recordingsMaxSizeMbLabel, border: const OutlineInputBorder(), ), ), const SizedBox(height: 16), FilledButton.icon( onPressed: () { final value = int.tryParse(controller.text.trim()); if (value == null || value <= 0) return; Navigator.of(ctx).pop(value); }, icon: const Icon(Icons.save_rounded), label: Text(l10n.saveQuickAccessButton), ), ], ), ); }, ); controller.dispose(); if (nuevoMb == null || !context.mounted) return; await estado.cambiarMaxBytesGrabacion(nuevoMb * 1024 * 1024); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))), ); } int _bytesAMegabytes(int bytes) => (bytes / (1024 * 1024)).round().clamp(1, 1048576); @override Widget build(BuildContext context) { final estado = context.watch(); final l10n = AppLocalizations.of(context); return PluriGlassSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.radio_button_checked), const SizedBox(width: 12), Text( l10n.recordingsSectionTitle, style: Theme.of(context).textTheme.titleMedium, ), ], ), FutureBuilder( future: estado.directorioGrabacionEfectivo(), builder: (ctx, snap) => ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.folder_outlined), title: Text(l10n.recordingsFolderTitle), subtitle: Text( snap.data ?? l10n.recordingsPathCalculating, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ), Wrap( spacing: 8, runSpacing: 8, children: [ OutlinedButton.icon( icon: const Icon(Icons.folder_open_rounded), label: Text(l10n.recordingsChangePath), onPressed: () => _seleccionarRuta(context), ), FilledButton.tonalIcon( icon: const Icon(Icons.folder_copy_rounded), label: Text(l10n.recordingsOpenFolder), onPressed: () => _abrirCarpeta(context), ), IconButton.filledTonal( tooltip: l10n.recordingsUseDefaultPath, icon: const Icon(Icons.restore_rounded), onPressed: () => _restaurarRuta(context), ), ], ), const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.sd_storage_rounded), title: Text(l10n.recordingsMaxSizeTitle), subtitle: Text( l10n.recordingsMaxSizeSubtitle( _bytesAMegabytes(estado.maxBytesGrabacion), ), ), onTap: () => _editarTamanoMaximo(context), ), const SizedBox(height: 8), Text( l10n.recordingsOriginalStreamHint, style: Theme.of(context).textTheme.bodySmall, ), ], ), ); } } class _SeccionTimerSueno extends StatelessWidget { const _SeccionTimerSueno(); Future _anadirPreset(BuildContext context) async { final l10n = AppLocalizations.of(context); final duracion = await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (_) => const _FormularioDuracionTimer(), ); if (duracion == null || !context.mounted) return; await context.read().agregarTimerSuenoPreset(duracion); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '${l10n.saveQuickAccessButton}: ${_formatearDuracionTimer(duracion)}', ), ), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final estado = context.watch(); final presets = estado.timerSuenoPresetsSegundos; return PluriGlassSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.bedtime_rounded), const SizedBox(width: 12), Text( l10n.timerSectionTitle, style: Theme.of(context).textTheme.titleMedium, ), const Spacer(), TextButton.icon( icon: const Icon(Icons.add_rounded), label: Text(l10n.timerSectionAdd), onPressed: () => _anadirPreset(context), ), ], ), const SizedBox(height: 8), Text( l10n.timerSectionDescription, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ for (final segundos in presets) InputChip( label: Text( _formatearDuracionTimer(Duration(seconds: segundos)), ), onDeleted: presets.length <= 1 ? null : () => context .read() .eliminarTimerSuenoPreset(segundos), ), ], ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: TextButton.icon( icon: const Icon(Icons.restore_rounded), label: Text(l10n.timerSectionRestoreRecommended), onPressed: () => context.read().restaurarTimerSuenoPresets(), ), ), ], ), ); } } class _SeccionIdioma extends StatelessWidget { const _SeccionIdioma(); static const _codigoSistema = 'system'; static const _idiomas = [ _IdiomaDisponible(Locale('en'), 'English'), _IdiomaDisponible(Locale('es'), 'Español'), _IdiomaDisponible(Locale('zh'), '中文'), _IdiomaDisponible(Locale('hi'), 'हिन्दी'), _IdiomaDisponible(Locale('ar'), 'العربية'), _IdiomaDisponible(Locale('pt'), 'Português'), _IdiomaDisponible(Locale('fr'), 'Français'), _IdiomaDisponible(Locale('ru'), 'Русский'), _IdiomaDisponible(Locale('de'), 'Deutsch'), _IdiomaDisponible(Locale('ja'), '日本語'), _IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'), _IdiomaDisponible(Locale('bn'), 'বাংলা'), _IdiomaDisponible(Locale('it'), 'Italiano'), ]; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final estadoIdioma = context.watch(); final locale = estadoIdioma.localeSeleccionado; final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale); return PluriGlassSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.language_rounded), const SizedBox(width: 12), Text( l10n.languageSectionTitle, style: Theme.of(context).textTheme.titleMedium, ), ], ), const SizedBox(height: 8), Text( l10n.languageSectionDescription, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: valorActual, decoration: InputDecoration( labelText: l10n.languageSectionTitle, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem( value: _codigoSistema, child: Text(l10n.languageSystemDefault), ), for (final idioma in _idiomas) DropdownMenuItem( value: _codigoLocale(idioma.locale), child: Text(idioma.nombreNativo), ), ], onChanged: (codigo) async { if (codigo == null) return; if (codigo == _codigoSistema) { await context.read().seleccionarSistema(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.languageUpdatedSystem)), ); return; } final idioma = _idiomas.firstWhere( (item) => _codigoLocale(item.locale) == codigo, orElse: () => _idiomas.first, ); await context.read().seleccionarLocale( idioma.locale, ); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l10n.languageUpdated(idioma.nombreNativo)), ), ); }, ), ], ), ); } static String _codigoLocale(Locale locale) { final countryCode = locale.countryCode; if (countryCode == null || countryCode.isEmpty) { return locale.languageCode; } return '${locale.languageCode}_$countryCode'; } } class _IdiomaDisponible { const _IdiomaDisponible(this.locale, this.nombreNativo); final Locale locale; final String nombreNativo; } class _FormularioDuracionTimer extends StatefulWidget { const _FormularioDuracionTimer(); @override State<_FormularioDuracionTimer> createState() => _FormularioDuracionTimerState(); } class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { final _horasCtrl = TextEditingController(); final _minutosCtrl = TextEditingController(text: '15'); final _segundosCtrl = TextEditingController(); @override void dispose() { _horasCtrl.dispose(); _minutosCtrl.dispose(); _segundosCtrl.dispose(); super.dispose(); } int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0; void _guardar() { final l10n = AppLocalizations.of(context); final duracion = Duration( hours: _leer(_horasCtrl), minutes: _leer(_minutosCtrl), seconds: _leer(_segundosCtrl), ); if (duracion <= Duration.zero) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero))); return; } Navigator.pop(context, duracion); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final bottom = MediaQuery.viewInsetsOf(context).bottom; return SafeArea( child: Padding( padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.newQuickAccessTitle, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), Row( children: [ Expanded(child: _campo(_horasCtrl, l10n.hoursLabel)), const SizedBox(width: 8), Expanded(child: _campo(_minutosCtrl, l10n.minutesLabel)), const SizedBox(width: 8), Expanded(child: _campo(_segundosCtrl, l10n.secondsLabel)), ], ), const SizedBox(height: 16), FilledButton.icon( icon: const Icon(Icons.save_rounded), label: Text(l10n.saveQuickAccessButton), onPressed: _guardar, ), ], ), ), ); } Widget _campo(TextEditingController controller, String label) { return TextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), ), ); } } class _SeccionEcualizador extends StatelessWidget { const _SeccionEcualizador(); @override Widget build(BuildContext context) { return Consumer( builder: (ctx, estado, _) { final disponible = estado.ecualizadorDisponible; final l10n = AppLocalizations.of(ctx); 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( l10n.equalizerTitle, style: Theme.of(ctx).textTheme.titleMedium, ), const Spacer(), Chip( label: Text( estado.ecualizadorActivo ? l10n.equalizerActive : l10n.equalizerDisabled, ), visualDensity: VisualDensity.compact, ), ], ), const SizedBox(height: 8), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(l10n.equalizerEnable), subtitle: Text( disponible ? l10n.equalizerRealtimeSubtitle : l10n.equalizerPendingSubtitle, ), value: estado.ecualizadorActivo, onChanged: estado.cambiarEcualizadorActivo, ), if (mostrarModoPorEmisora) ...[ const SizedBox(height: 8), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(l10n.equalizerPerStationTitle), subtitle: Text( usandoEqPropio ? l10n.equalizerPerStationActive(emisoraActual.nombre) : l10n.equalizerPerStationMain(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 _SeccionOrdenListas extends StatelessWidget { const _SeccionOrdenListas(); @override Widget build(BuildContext context) { final estado = context.watch(); final l10n = AppLocalizations.of(context); return PluriGlassSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.sort_rounded), const SizedBox(width: 12), Text( l10n.stationOrderTitle, style: Theme.of(context).textTheme.titleMedium, ), ], ), const SizedBox(height: 8), SegmentedButton( segments: [ ButtonSegment( value: OrdenEmisoras.nombre, icon: const Icon(Icons.sort_by_alpha_rounded), label: Text(l10n.stationOrderByName), ), ButtonSegment( value: OrdenEmisoras.calidad, icon: const Icon(Icons.hd_rounded), label: Text(l10n.stationOrderByQuality), ), ], selected: {estado.ordenListas}, onSelectionChanged: (value) { estado.cambiarOrdenListas(value.first); }, ), const SizedBox(height: 8), Text( l10n.stationOrderScopeDescription, style: Theme.of(context).textTheme.bodySmall, ), ], ), ); } } class _SeccionGruposFavoritos extends StatelessWidget { const _SeccionGruposFavoritos(); Future _editarGrupo( BuildContext context, [ GrupoFavoritos? grupo, ]) async { final l10n = AppLocalizations.of(context); final controller = TextEditingController(text: grupo?.nombre ?? ''); final nombre = await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (ctx) { final bottom = MediaQuery.viewInsetsOf(ctx).bottom; return Padding( padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( grupo == null ? l10n.favoriteGroupsAdd : l10n.favoriteGroupsEdit, style: Theme.of(ctx).textTheme.titleLarge, ), const SizedBox(height: 16), TextField( controller: controller, autofocus: true, maxLength: 28, decoration: InputDecoration( labelText: l10n.favoriteGroupsNameLabel, helperText: l10n.favoriteGroupsNameTooLong, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), FilledButton.icon( icon: const Icon(Icons.save_rounded), label: Text(AppLocalizations.of(ctx).saveQuickAccessButton), onPressed: () { final value = controller.text.trim(); if (value.isEmpty || value.length > 28) return; Navigator.pop(ctx, value); }, ), ], ), ); }, ); controller.dispose(); if (nombre == null || !context.mounted) return; final estado = context.read(); if (grupo == null) { await estado.crearGrupoFavoritos(nombre); } else { await estado.renombrarGrupoFavoritos(grupo.id, nombre); } if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( grupo == null ? l10n.favoriteGroupsCreated : l10n.favoriteGroupsUpdated, ), ), ); } Future _eliminarGrupo( BuildContext context, GrupoFavoritos grupo, ) async { final l10n = AppLocalizations.of(context); await context.read().eliminarGrupoFavoritos(grupo.id); if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted))); } String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) => grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre; @override Widget build(BuildContext context) { final estado = context.watch(); final l10n = AppLocalizations.of(context); final grupos = estado.gruposFavoritos; return PluriGlassSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.playlist_add_check_circle_rounded), const SizedBox(width: 12), Expanded( child: Text( l10n.favoriteGroupsTitle, style: Theme.of(context).textTheme.titleMedium, ), ), TextButton.icon( icon: const Icon(Icons.add_rounded), label: Text(l10n.favoriteGroupsAdd), onPressed: () => _editarGrupo(context), ), ], ), const SizedBox(height: 4), Text(l10n.favoriteGroupsDescription), const SizedBox(height: 8), for (final grupo in grupos) ListTile( contentPadding: EdgeInsets.zero, leading: Icon( grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded, ), title: Text(_nombreVisible(l10n, grupo)), subtitle: grupo.esSinAsignar ? Text(l10n.favoriteGroupsProtectedHint) : null, trailing: grupo.esSinAsignar ? null : Wrap( spacing: 4, children: [ IconButton( tooltip: l10n.favoriteGroupsEdit, icon: const Icon(Icons.edit_rounded), onPressed: () => _editarGrupo(context, grupo), ), IconButton( tooltip: l10n.favoriteGroupsDelete, icon: const Icon(Icons.delete_outline_rounded), onPressed: () => _eliminarGrupo(context, grupo), ), ], ), ), ], ), ); } } class _SeccionEmisoraPreferida extends StatelessWidget { const _SeccionEmisoraPreferida(); @override Widget build(BuildContext context) { final estado = context.watch(); final l10n = AppLocalizations.of(context); final favoritas = estado.listaFavoritos; final preferida = estado.emisoraPreferida; final opciones = _opciones(estado, preferida); return PluriGlassSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.radio_rounded), const SizedBox(width: 12), Text( l10n.preferredStationTitle, style: Theme.of(context).textTheme.titleMedium, ), ], ), const SizedBox(height: 8), Text( l10n.preferredStationDescription, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), if (opciones.isEmpty) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.info_outline_rounded), title: Text(l10n.preferredStationNoStationsTitle), subtitle: Text(l10n.preferredStationNoStationsSubtitle), ) else DropdownButtonFormField( initialValue: preferida?.uuid, decoration: InputDecoration( labelText: favoritas.isEmpty ? l10n.preferredStationAutomaticFallback : l10n.preferredStationDefaultFavorite, ), items: [ for (final emisora in opciones) DropdownMenuItem( value: emisora.uuid, child: Text( emisora.nombre, overflow: TextOverflow.ellipsis, ), ), ], onChanged: (uuid) async { final seleccion = opciones.firstWhere((e) => e.uuid == uuid); await context.read().cambiarEmisoraPreferida( seleccion, ); }, ), if (preferida != null) ...[ const SizedBox(height: 8), Text( favoritas.any((e) => e.uuid == preferida.uuid) ? l10n.preferredStationCurrent(preferida.nombre) : l10n.preferredStationAutoUsing(preferida.nombre), ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( icon: const Icon(Icons.play_arrow_rounded), label: Text(l10n.preferredStationPlay), onPressed: () => context .read() .reproducirEmisoraPreferida(), ), ), ], ], ), ); } List _opciones(EstadoRadio estado, Emisora? preferida) { final base = estado.listaFavoritos.isNotEmpty ? estado.listaFavoritos : estado.emisorasDisponiblesPreferencia; final mapa = { for (final emisora in base) emisora.uuid: emisora, }; if (preferida != null) { mapa[preferida.uuid] = preferida; } return mapa.values.toList(); } } class _SeccionEmisoras extends StatelessWidget { const _SeccionEmisoras(); @override Widget build(BuildContext context) { final estado = context.watch(); 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( AppLocalizations.of(context).customStationsTitle, 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) Padding( padding: const EdgeInsets.only(top: 8), child: Text( AppLocalizations.of(context).customStationsEmpty, style: const 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: AppLocalizations.of(context).playAction, onPressed: () => context.read().reproducir(emisora), ), IconButton( icon: const Icon(Icons.delete_outline), tooltip: AppLocalizations.of(context).deleteAction, onPressed: () => context .read() .eliminarEmitoraCustom(emisora.uuid), ), ], ), ), ], ), ); } Future _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(); final _nombreCtrl = TextEditingController(); final _urlCtrl = TextEditingController(); final _paisCtrl = TextEditingController(); bool _guardando = false; @override void dispose() { _nombreCtrl.dispose(); _urlCtrl.dispose(); _paisCtrl.dispose(); super.dispose(); } Future _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().agregarEmitoraCustom(emisora); if (mounted) Navigator.pop(context); } @override Widget build(BuildContext context) { final bottom = MediaQuery.of(context).viewInsets.bottom; return Padding( padding: EdgeInsets.fromLTRB( PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal + 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: InputDecoration( labelText: AppLocalizations.of(context).stationNameLabel, border: const OutlineInputBorder(), ), validator: (v) => v == null || v.trim().isEmpty ? AppLocalizations.of(context).requiredField : null, ), const SizedBox(height: 12), TextFormField( controller: _urlCtrl, decoration: InputDecoration( labelText: AppLocalizations.of(context).streamUrlLabel, hintText: 'http://stream.ejemplo.com:8000/radio', border: const OutlineInputBorder(), ), keyboardType: TextInputType.url, validator: (v) { if (v == null || v.trim().isEmpty) { return AppLocalizations.of(context).requiredField; } 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), ) : Text(AppLocalizations.of(context).saveStation), ), ], ), ), ); } } class _SeccionBackup extends StatelessWidget { const _SeccionBackup(); Future _exportar(BuildContext context) async { try { final estado = context.read(); 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( AppLocalizations.of(context).backupExportError(e.toString()), ), ), ); } } } Future _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; if (context.mounted) { final confirmar = await showDialog( 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: Text(AppLocalizations.of(ctx).cancelAction), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(AppLocalizations.of(ctx).backupImportTitle), ), ], ), ); if (confirmar != true) return; if (context.mounted) { final estado = context.read(); 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( AppLocalizations.of(context).backupImportError(e.toString()), ), ), ); } } } @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( AppLocalizations.of(context).backupSectionTitle, style: Theme.of(context).textTheme.titleMedium, ), ], ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.upload_outlined), title: const Text('Exportar configuración'), subtitle: Text(AppLocalizations.of(context).backupExportSubtitle), onTap: () => _exportar(context), ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.download_outlined), title: const Text('Importar configuración'), subtitle: Text(AppLocalizations.of(context).backupImportSubtitle), onTap: () => _importar(context), ), ], ), ); } } class _SeccionInfo extends StatelessWidget { const _SeccionInfo(); @override Widget build(BuildContext context) { return Consumer( builder: (ctx, estado, _) => PluriGlassSurface( child: Column( children: [ FutureBuilder( 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( AppLocalizations.of(ctx).appVersionSubtitle(version), ), ); }, ), FutureBuilder( future: estado.favoritos.obtenerTodos().then((l) => l.length), builder: (ctx, snap) => ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.favorite_outline), title: Text( AppLocalizations.of(ctx).savedFavoritesTitle, ), trailing: Text( snap.data?.toString() ?? '—', style: Theme.of(ctx).textTheme.bodyLarge, ), ), ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.help_outline_rounded), title: Text(_helpTitle(ctx)), subtitle: Text(_helpSubtitle(ctx)), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => PluriOnboardingDialog.mostrar(ctx), ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.verified_outlined), title: Text(AppLocalizations.of(ctx).stationFilterTitle), subtitle: Text( AppLocalizations.of(ctx).stationFilterSubtitle, ), trailing: const 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), ), ], ), ), ); } } String _helpTitle(BuildContext context) => switch (Localizations.localeOf( context, ).languageCode) { 'es' => 'Ayuda y tutorial', 'fr' => 'Aide et tutoriel', 'de' => 'Hilfe und Tutorial', 'it' => 'Aiuto e tutorial', 'pt' => 'Ajuda e tutorial', _ => 'Help and tutorial', }; String _helpSubtitle(BuildContext context) => switch (Localizations.localeOf( context, ).languageCode) { 'es' => 'Repasá funciones, consejos y novedades de PluriWave.', 'fr' => 'Revoyez les fonctions, conseils et nouveautés de PluriWave.', 'de' => 'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.', 'it' => 'Rivedi funzioni, consigli e novità di PluriWave.', 'pt' => 'Revê funções, dicas e novidades do PluriWave.', _ => 'Review PluriWave features, tips and what’s new.', }; String _formatearDuracionTimer(Duration duracion) { final horas = duracion.inHours; final minutos = duracion.inMinutes.remainder(60); final segundos = duracion.inSeconds.remainder(60); if (horas > 0) { return '${horas}h ${minutos.toString().padLeft(2, '0')}m ${segundos.toString().padLeft(2, '0')}s'; } if (minutos > 0) { return segundos == 0 ? '$minutos min' : '${minutos}m ${segundos}s'; } return '$segundos s'; }