import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart' show Locale; import 'package:geocoding/geocoding.dart'; import 'package:geolocator/geolocator.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/preset_ecualizador.dart'; import '../servicios/servicio_alarmas_android.dart'; import '../servicios/servicio_audio.dart'; import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_favoritos.dart'; import '../servicios/servicio_grabacion_radio.dart'; import '../servicios/servicio_radio.dart'; import '../servicios/servicio_timer.dart'; enum OrdenEmisoras { nombre, calidad } /// Estado global de la app con ChangeNotifier (Provider). class EstadoRadio extends ChangeNotifier { static const MethodChannel _fileActionsChannel = MethodChannel( 'pluriwave/file_actions', ); EstadoRadio({ ServicioAudio? audio, ServicioFavoritos? favoritos, ServicioRadio? radio, ServicioEcualizador? servicioEcualizador, ServicioGrabacionRadio? servicioGrabacion, Future Function()? resolverArchivoCustom, bool iniciarAutomaticamente = true, }) : audio = audio ?? ServicioAudio(), favoritos = favoritos ?? ServicioFavoritos(), radio = radio ?? ServicioRadio(), servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(), grabacion = servicioGrabacion ?? ServicioGrabacionRadio(), _resolverArchivoCustom = resolverArchivoCustom { timer = ServicioTimer(this.audio); _escucharErroresReproduccion(); _escucharGrabacion(); if (iniciarAutomaticamente) { _initFuture = _init(); } } final ServicioAudio audio; final ServicioFavoritos favoritos; final ServicioRadio radio; final ServicioEcualizador servicioEcualizador; final ServicioGrabacionRadio grabacion; final Future Function()? _resolverArchivoCustom; AppLocalizations get _textos { final actual = _l10n; if (actual != null) return actual; return lookupAppLocalizations(const Locale('es')); } void configurarLocalizaciones(AppLocalizations l10n) { _l10n = l10n; audio.configurarLocalizaciones(l10n); grabacion.configurarLocalizaciones(l10n); ServicioAlarmasAndroid.configurarLocalizaciones(l10n); } late final ServicioTimer timer; StreamSubscription? _suscripcionEstadoAudio; StreamSubscription? _suscripcionGrabacion; Future? _initFuture; int _revisionReproduccion = 0; Emisora? _emisoraSeleccionada; String? _emisoraPreferidaUuid; AppLocalizations? _l10n; // Errores de reproducción → SnackBar. final _errorController = StreamController.broadcast(); Stream get errorStream => _errorController.stream; List _populares = []; List _tendencias = []; List _resultadosBusqueda = []; List _emisorasCercanas = []; List _listaFavoritos = []; List _gruposFavoritos = []; List _emisorasCustom = []; // Presets EQ guardados por uuid de emisora. final Map _presetsEmisoraMap = {}; PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; bool _ecualizadorActivo = true; bool _cargandoPopulares = false; bool _cargandoBusqueda = false; bool _cargandoMasBusqueda = false; bool _hayMasBusqueda = true; bool _cargandoCercanas = false; String? _paisCercanoDetectado; String? _errorCercanas; int _offsetBusqueda = 0; String? _ultimoNombreBusqueda; String? _ultimoPaisBusqueda; String? _ultimoIdiomaBusqueda; String? _ultimoTagBusqueda; int? _ultimoMinBitrateBusqueda; String? _errorCarga; static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; static const _keyOrdenListas = 'orden_listas_emisoras_v1'; static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1'; static const _timerSuenoPresetsDefecto = [ 180, 300, 600, 900, 1800, 3600, 5400, 7200, 10800, ]; List _timerSuenoPresetsSegundos = List.from( _timerSuenoPresetsDefecto, ); OrdenEmisoras _ordenListas = OrdenEmisoras.calidad; List get populares => _ordenarEmisoras(_populares); List get tendencias => _ordenarEmisoras(_tendencias); List get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda); List get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas); List get listaFavoritos => _ordenarEmisoras(_listaFavoritos); List get gruposFavoritos => List.unmodifiable(_gruposFavoritos); List get emisorasCustom => _ordenarEmisoras(_emisorasCustom); bool get cargandoPopulares => _cargandoPopulares; bool get cargandoBusqueda => _cargandoBusqueda; bool get cargandoMasBusqueda => _cargandoMasBusqueda; bool get hayMasBusqueda => _hayMasBusqueda; bool get cargandoCercanas => _cargandoCercanas; String? get paisCercanoDetectado => _paisCercanoDetectado; String? get errorCercanas => _errorCercanas; String? get error => _errorCarga; Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual; Emisora? get emisoraPreferida => _resolverEmisoraPreferida(); String? get emisoraPreferidaUuid => emisoraPreferida?.uuid; Stream get estadoStream => audio.estadoStream; PresetEcualizador get presetEcualizador => _presetActual; PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal; bool get ecualizadorActivo => _ecualizadorActivo; bool get ecualizadorDisponible => audio.ecualizadorDisponible; OrdenEmisoras get ordenListas => _ordenListas; List get timerSuenoPresetsSegundos => List.unmodifiable(_timerSuenoPresetsSegundos); bool get emisoraActualEsFavorita { final actual = emisoraActual; if (actual == null) return false; return _listaFavoritos.any((e) => e.uuid == actual.uuid); } bool get emisoraActualTienePresetPropio { final actual = emisoraActual; if (actual == null) return false; return tienePresetEcualizadorPorEmisora(actual.uuid); } EstadoGrabacionRadio get estadoGrabacion => grabacion.estado; bool get grabacionActiva => grabacion.estado.activa; String? get directorioGrabacion => grabacion.directorioConfigurado; int get maxBytesGrabacion => grabacion.maxBytes; File? get ultimaGrabacion => grabacion.ultimoArchivo; /// Lista principal (home): custom + populares, sin duplicados. List get emisorasInicio { final mapa = {}; for (final emisora in _emisorasCustom) { mapa[emisora.uuid] = emisora; } for (final emisora in _populares) { mapa.putIfAbsent(emisora.uuid, () => emisora); } return mapa.values.toList(); } List get emisorasDisponiblesPreferencia { final mapa = {}; for (final emisora in _listaFavoritos) { mapa[emisora.uuid] = emisora; } for (final emisora in _emisorasCustom) { mapa.putIfAbsent(emisora.uuid, () => emisora); } for (final emisora in _populares) { mapa.putIfAbsent(emisora.uuid, () => emisora); } for (final emisora in _tendencias) { mapa.putIfAbsent(emisora.uuid, () => emisora); } for (final emisora in _resultadosBusqueda) { mapa.putIfAbsent(emisora.uuid, () => emisora); } for (final emisora in _emisorasCercanas) { mapa.putIfAbsent(emisora.uuid, () => emisora); } return mapa.values.toList(); } Future inicializar() { _initFuture ??= _init(); return _initFuture!; } Future _init() async { await grabacion.inicializar(); await _cargarEcualizadorPersistido(); await _cargarOrdenListas(); await _cargarEmisoraPreferida(); await _cargarTimerSuenoPresets(); await Future.wait([ cargarPopulares(), cargarFavoritos(), cargarGruposFavoritos(), _cargarEmisorasCustom(), ]); await _normalizarEmisoraPreferida(); } /// Escucha el stream de estado del audio y gestiona errores de reproducción. void _escucharErroresReproduccion() { _suscripcionEstadoAudio = audio.estadoStream.listen((estado) { if (estado == EstadoReproduccion.error && timer.activo) { unawaited(timer.cancelar()); } if ((estado == EstadoReproduccion.detenido || estado == EstadoReproduccion.pausado || estado == EstadoReproduccion.error) && grabacion.estado.activa) { unawaited(grabacion.detener()); } notifyListeners(); }); } void _escucharGrabacion() { _suscripcionGrabacion = grabacion.estadoStream.listen((estado) { if (estado.tipo == EstadoGrabacionRadioTipo.error && estado.error != null) { _errorController.add(_textos.radioRecordingError(estado.error!)); } notifyListeners(); }); } Future _cargarEcualizadorPersistido() async { try { final config = await servicioEcualizador.cargar(); _presetPrincipal = config.principal; _presetActual = config.principal; _ecualizadorActivo = config.activo; _presetsEmisoraMap ..clear() ..addAll(config.porEmisora); await audio.setEcualizadorActivo(_ecualizadorActivo); await audio.aplicarPreset(_presetPrincipal); } catch (_) { _presetPrincipal = PresetEcualizador.flat; _presetActual = PresetEcualizador.flat; _ecualizadorActivo = true; _presetsEmisoraMap.clear(); } } Future cargarPopulares() async { _cargandoPopulares = true; _errorCarga = null; notifyListeners(); try { final results = await Future.wait([ radio.obtenerPopulares(limit: 30), radio.obtenerTendencias(limit: 20), ]); _populares = results[0]; _tendencias = results[1]; } catch (_) { _errorCarga = _textos.radioApiConnectionError; } finally { _cargandoPopulares = false; notifyListeners(); } } Future cargarFavoritos() async { _listaFavoritos = await favoritos.obtenerTodos(); await _normalizarEmisoraPreferida(); notifyListeners(); } Future cargarGruposFavoritos() async { _gruposFavoritos = await favoritos.obtenerGrupos(); notifyListeners(); } Future crearGrupoFavoritos(String nombre) async { await favoritos.crearGrupo(nombre); await cargarGruposFavoritos(); } Future renombrarGrupoFavoritos(String id, String nombre) async { await favoritos.renombrarGrupo(id, nombre); await cargarGruposFavoritos(); } Future eliminarGrupoFavoritos(String id) async { await favoritos.eliminarGrupo(id); await Future.wait([cargarFavoritos(), cargarGruposFavoritos()]); } Future asignarGrupoFavorito(String uuid, String grupoId) async { await favoritos.asignarGrupo(uuid, grupoId); await cargarFavoritos(); } Future cambiarEmisoraPreferida(Emisora? emisora) async { _emisoraPreferidaUuid = emisora?.uuid; final prefs = await SharedPreferences.getInstance(); if (_emisoraPreferidaUuid == null) { await prefs.remove(_keyEmisoraPreferida); } else { await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!); } notifyListeners(); } Future reproducirEmisoraPreferida() async { final preferida = emisoraPreferida; if (preferida == null) return; await reproducir(preferida); } Future _cargarTimerSuenoPresets() async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_keyTimerSuenoPresets); if (raw == null) return; final decoded = jsonDecode(raw); if (decoded is! List) return; final presets = decoded .whereType() .map((n) => n.toInt()) .where((s) => s > 0) .toSet() .toList() ..sort(); if (presets.isNotEmpty) { _timerSuenoPresetsSegundos = presets.take(12).toList(); } } catch (_) { _timerSuenoPresetsSegundos = List.from(_timerSuenoPresetsDefecto); } } Future _cargarEmisoraPreferida() async { final prefs = await SharedPreferences.getInstance(); _emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida); } Future _cargarOrdenListas() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_keyOrdenListas); _ordenListas = switch (raw) { 'nombre' => OrdenEmisoras.nombre, 'calidad' => OrdenEmisoras.calidad, _ => OrdenEmisoras.calidad, }; } Future cambiarOrdenListas(OrdenEmisoras orden) async { _ordenListas = orden; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_keyOrdenListas, orden.name); notifyListeners(); } Future _normalizarEmisoraPreferida() async { final preferida = _resolverEmisoraPreferida(); if (preferida?.uuid == _emisoraPreferidaUuid) return; _emisoraPreferidaUuid = preferida?.uuid; final prefs = await SharedPreferences.getInstance(); if (_emisoraPreferidaUuid == null) { await prefs.remove(_keyEmisoraPreferida); } else { await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!); } } Emisora? _resolverEmisoraPreferida() { final uuid = _emisoraPreferidaUuid; if (uuid != null) { for (final emisora in _listaFavoritos) { if (emisora.uuid == uuid) return emisora; } } if (_listaFavoritos.isNotEmpty) return _listaFavoritos.first; if (uuid != null) { for (final emisora in emisorasDisponiblesPreferencia) { if (emisora.uuid == uuid) return emisora; } } final disponibles = emisorasDisponiblesPreferencia; return disponibles.isEmpty ? null : disponibles.first; } static const int _tamanoPaginaBusqueda = 30; static const int _maxResultadosBusquedaEnMemoria = 180; Future buscar({ String? nombre, String? pais, String? idioma, String? tag, int? minBitrate, }) async { _ultimoNombreBusqueda = nombre; _ultimoPaisBusqueda = pais; _ultimoIdiomaBusqueda = idioma; _ultimoTagBusqueda = tag; _ultimoMinBitrateBusqueda = minBitrate; _offsetBusqueda = 0; _hayMasBusqueda = true; _cargandoBusqueda = true; _resultadosBusqueda = []; notifyListeners(); try { final pagina = await _buscarPaginaFiltrada( nombre: nombre, pais: pais, idioma: idioma, tag: tag, minBitrate: minBitrate, ); _resultadosBusqueda = pagina; } catch (_) { _errorController.add(_textos.radioSearchError); } finally { _cargandoBusqueda = false; notifyListeners(); } } Future cargarMasBusqueda() async { if (_cargandoBusqueda || _cargandoMasBusqueda || !_hayMasBusqueda) return; _cargandoMasBusqueda = true; notifyListeners(); try { final pagina = await _buscarPaginaFiltrada( nombre: _ultimoNombreBusqueda, pais: _ultimoPaisBusqueda, idioma: _ultimoIdiomaBusqueda, tag: _ultimoTagBusqueda, minBitrate: _ultimoMinBitrateBusqueda, ); final porUuid = { for (final emisora in _resultadosBusqueda) emisora.uuid: emisora, }; for (final emisora in pagina) { porUuid[emisora.uuid] = emisora; } var nuevaLista = porUuid.values.toList(); if (nuevaLista.length > _maxResultadosBusquedaEnMemoria) { nuevaLista = nuevaLista.sublist( nuevaLista.length - _maxResultadosBusquedaEnMemoria, ); } _resultadosBusqueda = nuevaLista; // _buscarPaginaFiltrada actualiza offset/hayMas usando páginas crudas. _hayMasBusqueda = _hayMasBusqueda && pagina.isNotEmpty; } catch (_) { _errorController.add(_textos.radioLoadMoreStationsError); } finally { _cargandoMasBusqueda = false; notifyListeners(); } } Future> _buscarPaginaFiltrada({ String? nombre, String? pais, String? idioma, String? tag, int? minBitrate, }) async { final acumuladas = []; var intentos = 0; while (intentos < 4 && acumuladas.isEmpty && _hayMasBusqueda) { final pagina = await radio.buscar( nombre: nombre, pais: pais, idioma: idioma, tag: tag, limit: _tamanoPaginaBusqueda, offset: _offsetBusqueda, ); _offsetBusqueda += pagina.length; _hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda; acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate)); intentos++; } return acumuladas; } List _filtrarMinBitrate(List emisoras, int? minBitrate) { if (minBitrate == null || minBitrate <= 0) return emisoras; return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList(); } List _ordenarEmisoras(List emisoras) { final ordenadas = List.from(emisoras); switch (_ordenListas) { case OrdenEmisoras.nombre: ordenadas.sort( (a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()), ); case OrdenEmisoras.calidad: ordenadas.sort((a, b) { final porBitrate = (b.bitrate ?? 0).compareTo(a.bitrate ?? 0); if (porBitrate != 0) return porBitrate; return 0; }); } return ordenadas; } Future cargarEmisorasCercanas() async { _cargandoCercanas = true; _errorCercanas = null; notifyListeners(); try { var pais = PlatformDispatcher.instance.locale.countryCode; final servicioActivo = await Geolocator.isLocationServiceEnabled(); if (servicioActivo) { var permiso = await Geolocator.checkPermission(); if (permiso == LocationPermission.denied) { permiso = await Geolocator.requestPermission(); } if (permiso == LocationPermission.always || permiso == LocationPermission.whileInUse) { final posicion = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.low, timeLimit: Duration(seconds: 8), ), ); final marcas = await placemarkFromCoordinates( posicion.latitude, posicion.longitude, ); if (marcas.isNotEmpty) { pais = marcas.first.isoCountryCode ?? pais; } } } if (pais == null || pais.isEmpty) { throw StateError('nearby-region-not-detected'); } _paisCercanoDetectado = pais; _emisorasCercanas = _filtrarMinBitrate( await radio.buscar(pais: pais, limit: 30), _ultimoMinBitrateBusqueda, ); } catch (_) { _errorCercanas = _textos.radioNearbyStationsError; _emisorasCercanas = []; } finally { _cargandoCercanas = false; notifyListeners(); } } Future reproducir(Emisora emisora) async { final revision = ++_revisionReproduccion; if (grabacion.estado.activa) { await grabacion.detener(); } _emisoraSeleccionada = emisora; notifyListeners(); try { await audio.reproducir(emisora); if (revision != _revisionReproduccion) return; unawaited(radio.registrarClick(emisora.uuid)); await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid)); if (revision != _revisionReproduccion) return; notifyListeners(); } catch (e) { if (revision != _revisionReproduccion) return; if (timer.activo) { unawaited(timer.cancelar()); } final mensajeError = e.toString().replaceFirst('Exception: ', ''); _emisoraSeleccionada = audio.emisoraActual; _errorController.add( mensajeError.isNotEmpty && mensajeError != 'Exception' ? mensajeError : _textos.radioCannotPlayStation( localizedStationName(_textos, emisora.nombre), ), ); notifyListeners(); } } Future iniciarGrabacion({Duration? duracion}) async { final actual = emisoraActual; if (actual == null) { _errorController.add(_textos.recordingSelectStationFirst); return; } try { await grabacion.iniciar(actual, duracion: duracion); } catch (e) { _errorController.add(_textos.recordingStartError(e.toString())); } } Future detenerGrabacion() => grabacion.detener(); Future detenerReproduccion() async { if (grabacion.estado.activa) { await grabacion.detener(); } await audio.detener(); notifyListeners(); } Future cambiarMaxBytesGrabacion(int bytes) async { await grabacion.guardarMaxBytes(bytes); notifyListeners(); } Future abrirDirectorioGrabacion() async { final ruta = await directorioGrabacionEfectivo(); await Directory(ruta).create(recursive: true); if (!kIsWeb && Platform.isAndroid) { final abierto = await _fileActionsChannel.invokeMethod( 'viewDirectory', {'path': ruta}, ); return abierto ?? false; } final uri = Uri.directory(ruta); return launchUrl(uri, mode: LaunchMode.externalApplication); } Future abrirUltimaGrabacion() async { final archivo = ultimaGrabacion; if (archivo == null || !await archivo.exists()) { debugPrint('[PluriWave][recordings] last recording missing'); return false; } debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}'); if (!kIsWeb && Platform.isAndroid) { final abierto = await _fileActionsChannel.invokeMethod('openFile', { 'path': archivo.path, 'mimeType': 'audio/*', }); return abierto ?? false; } return launchUrl( Uri.file(archivo.path), mode: LaunchMode.externalApplication, ); } Future cambiarDirectorioGrabacion(String path) async { await grabacion.guardarDirectorio(path); notifyListeners(); } Future restaurarDirectorioGrabacion() async { await grabacion.limpiarDirectorioConfigurado(); notifyListeners(); } Future directorioGrabacionEfectivo() => grabacion.directorioEfectivo(); Future togglePlay() async { if (audio.estaSonando && grabacion.estado.activa) { await grabacion.detener(); } await audio.togglePlay(); notifyListeners(); } Future toggleFavorito(Emisora emisora) async { final esFav = await favoritos.toggleFavorito(emisora); if (!esFav) { await deshabilitarPresetEcualizadorPorEmisora( emisora.uuid, notificar: false, ); } await cargarFavoritos(); return esFav; } Future esFavorito(String uuid) => favoritos.esFavorito(uuid); // ── Ecualizador ─────────────────────────────────────────────────────────── bool tienePresetEcualizadorPorEmisora(String uuid) => _presetsEmisoraMap.containsKey(uuid); PresetEcualizador? presetEcualizadorPorEmisora(String uuid) => _presetsEmisoraMap[uuid]; PresetEcualizador _presetParaEmisora(String uuid) => _presetsEmisoraMap[uuid] ?? _presetPrincipal; Future _aplicarPresetActivo(PresetEcualizador preset) async { _presetActual = preset; await audio.aplicarPreset(preset); } Future cambiarPresetPrincipalEcualizador( PresetEcualizador preset, { bool notificar = true, }) async { _presetPrincipal = preset; await servicioEcualizador.guardarPrincipal(preset); final actual = emisoraActual; final puedeAplicarAhora = actual == null || !_presetsEmisoraMap.containsKey(actual.uuid); if (puedeAplicarAhora) { await _aplicarPresetActivo(preset); } if (notificar) notifyListeners(); } Future guardarPresetEcualizadorPorEmisora( String uuid, PresetEcualizador preset, { bool notificar = true, }) async { _presetsEmisoraMap[uuid] = preset; await servicioEcualizador.guardarPorEmisora(uuid, preset); if (emisoraActual?.uuid == uuid) { await _aplicarPresetActivo(preset); } if (notificar) notifyListeners(); } Future habilitarPresetEcualizadorPorEmisora( String uuid, { PresetEcualizador? base, bool notificar = true, }) async { final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal; await guardarPresetEcualizadorPorEmisora( uuid, presetBase, notificar: notificar, ); } Future deshabilitarPresetEcualizadorPorEmisora( String uuid, { bool notificar = true, }) async { _presetsEmisoraMap.remove(uuid); await servicioEcualizador.eliminarPorEmisora(uuid); if (emisoraActual?.uuid == uuid) { await _aplicarPresetActivo(_presetPrincipal); } if (notificar) notifyListeners(); } Future cambiarModoEcualizadorEmisoraActual({ required bool usarPropio, }) async { final actual = emisoraActual; if (actual == null) return; if (usarPropio) { await habilitarPresetEcualizadorPorEmisora(actual.uuid); } else { await deshabilitarPresetEcualizadorPorEmisora(actual.uuid); } } Future cambiarEcualizadorActivo(bool activo) async { _ecualizadorActivo = activo; await servicioEcualizador.guardarActivo(activo); await audio.setEcualizadorActivo(activo); if (activo) { await audio.aplicarPreset(_presetActual); } notifyListeners(); } Future cambiarPresetEcualizador( PresetEcualizador preset, { bool guardarPorEmisora = true, }) async { final actual = emisoraActual; final usarPresetPropio = guardarPorEmisora && actual != null && _presetsEmisoraMap.containsKey(actual.uuid); if (usarPresetPropio) { await guardarPresetEcualizadorPorEmisora(actual.uuid, preset); return; } await cambiarPresetPrincipalEcualizador(preset); } Future cambiarBandaEcualizador(int index, double db) async { final bandas = List.from(_presetActual.bandas); if (index < 0 || index >= bandas.length) return; bandas[index] = db; final modificado = PresetEcualizador( nombre: 'Personalizado', bandas: bandas, ); await cambiarPresetEcualizador(modificado); } // ── Emisoras personalizadas ─────────────────────────────────────────────── Future _archivoCustom() async { if (_resolverArchivoCustom != null) { return _resolverArchivoCustom(); } final dir = await getApplicationDocumentsDirectory(); return File('${dir.path}/emisoras_custom.json'); } Future _cargarEmisorasCustom() async { try { final archivo = await _archivoCustom(); if (!await archivo.exists()) { _emisorasCustom = []; notifyListeners(); return; } final data = jsonDecode(await archivo.readAsString()) as List; _emisorasCustom = data .map((e) => Emisora.fromMap(Map.from(e as Map))) .toList(); } catch (_) { _emisorasCustom = []; } notifyListeners(); } Future _guardarEmisorasCustom() async { final archivo = await _archivoCustom(); await archivo.writeAsString( jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()), ); } Future agregarEmisoraCustom(Emisora emisora) async { _emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid); _emisorasCustom.add(emisora); await _guardarEmisorasCustom(); notifyListeners(); } // Compatibilidad con el nombre histórico (typo original). Future agregarEmitoraCustom(Emisora emisora) => agregarEmisoraCustom(emisora); Future eliminarEmisoraCustom(String uuid) async { _emisorasCustom.removeWhere((e) => e.uuid == uuid); await _guardarEmisorasCustom(); notifyListeners(); } // Compatibilidad con el nombre histórico (typo original). Future eliminarEmitoraCustom(String uuid) => eliminarEmisoraCustom(uuid); // ── Export / Import ─────────────────────────────────────────────────────── static const _keyAlarmasConfig = 'alarmas_musicales_v1'; /// Genera el JSON de toda la configuración (v2 — portabilidad completa). Future> exportarConfig() async { final favs = await favoritos.obtenerTodos(); final grupos = await favoritos.obtenerGrupos(); final prefs = await SharedPreferences.getInstance(); // Alarmas: leemos el JSON crudo de SharedPreferences para no duplicar // lógica de ServicioAlarmas y evitar inyectar una dependencia nueva. final alarmasRaw = prefs.getString(_keyAlarmasConfig); final alarmasData = alarmasRaw != null ? jsonDecode(alarmasRaw) as Map : null; return { 'version': 2, 'exportedAt': DateTime.now().toIso8601String(), // Favoritos + grupos (preserva asignaciones grupo_id en cada emisora) 'gruposFavoritos': grupos .where((g) => !g.esSinAsignar) .map((g) => g.toMap()) .toList(), 'favoritos': favs.map((e) => e.toMap()).toList(), // Emisoras personalizadas 'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(), // Ecualizador 'presetPrincipalEcualizador': _presetPrincipal.toJson(), 'presetsEcualizador': _presetsEmisoraMap.map( (uuid, preset) => MapEntry(uuid, preset.toJson()), ), // Alarmas completas (alarmas + vacaciones + excepciones) 'alarmas': alarmasData, // Preferencias de usuario 'emisoraPreferidaUuid': _emisoraPreferidaUuid, 'ordenListas': _ordenListas.name, 'timerSuenoPresetsSegundos': _timerSuenoPresetsSegundos, }; } /// Importa configuración desde un JSON exportado previamente. /// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa). Future importarConfig(Map data) async { final version = data['version'] as int? ?? 1; if (version > 2) throw Exception(_textos.unsupportedConfigVersion); final prefs = await SharedPreferences.getInstance(); // ── Grupos de favoritos (v2) ────────────────────────────────────────── // Restauramos primero para que al agregar favoritos ya existan los grupos. if (version >= 2) { final gruposRaw = data['gruposFavoritos'] as List? ?? []; for (final raw in gruposRaw) { final g = GrupoFavoritos.fromMap(Map.from(raw as Map)); // Usamos insert directo para preservar id, orden y nombre originales. await favoritos.restaurarGrupo(g); } await cargarGruposFavoritos(); } // ── Favoritos ───────────────────────────────────────────────────────── final favRaw = data['favoritos'] as List? ?? []; for (final raw in favRaw) { final emisora = Emisora.fromMap(Map.from(raw as Map)); await favoritos.agregar(emisora); } // ── Emisoras custom ─────────────────────────────────────────────────── final customRaw = data['emisorasCustom'] as List? ?? []; _emisorasCustom = customRaw .map((e) => Emisora.fromMap(Map.from(e as Map))) .toList(); await _guardarEmisorasCustom(); // ── Ecualizador ─────────────────────────────────────────────────────── final principalRaw = data['presetPrincipalEcualizador']; if (principalRaw is Map) { _presetPrincipal = PresetEcualizador.desdeJson( Map.from(principalRaw), ); } else { _presetPrincipal = PresetEcualizador.flat; } final presetsRaw = data['presetsEcualizador'] as Map? ?? {}; _presetsEmisoraMap ..clear() ..addAll( presetsRaw.map( (uuid, presetJson) => MapEntry( uuid as String, PresetEcualizador.desdeJson( Map.from(presetJson as Map), ), ), ), ); await servicioEcualizador.guardarConfiguracion( ConfiguracionEcualizador( principal: _presetPrincipal, porEmisora: _presetsEmisoraMap, activo: _ecualizadorActivo, ), ); final actual = emisoraActual; final presetActivo = actual == null ? _presetPrincipal : _presetParaEmisora(actual.uuid); await _aplicarPresetActivo(presetActivo); // ── Alarmas (v2) ────────────────────────────────────────────────────── if (version >= 2) { final alarmasData = data['alarmas']; if (alarmasData is Map) { // Escribimos el bloque JSON tal como estaba en el dispositivo origen. // ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso. await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData)); } } // ── Preferencias de usuario (v2) ────────────────────────────────────── if (version >= 2) { final preferidaUuid = data['emisoraPreferidaUuid'] as String?; _emisoraPreferidaUuid = preferidaUuid; if (preferidaUuid == null) { await prefs.remove(_keyEmisoraPreferida); } else { await prefs.setString(_keyEmisoraPreferida, preferidaUuid); } final ordenRaw = data['ordenListas'] as String?; _ordenListas = switch (ordenRaw) { 'nombre' => OrdenEmisoras.nombre, 'calidad' => OrdenEmisoras.calidad, _ => OrdenEmisoras.calidad, }; await prefs.setString(_keyOrdenListas, _ordenListas.name); final timerPresetsRaw = data['timerSuenoPresetsSegundos'] as List?; if (timerPresetsRaw != null) { await guardarTimerSuenoPresetsSegundos( timerPresetsRaw.whereType().map((n) => n.toInt()).toList(), ); } } await cargarFavoritos(); notifyListeners(); } // ── Timer ───────────────────────────────────────────────────────────────── void iniciarTimer(int minutos) { timer.iniciar(minutos); notifyListeners(); } void iniciarTimerDuracion(Duration duracion) { timer.iniciarDuracion(duracion); notifyListeners(); } void cancelarTimer() { unawaited(timer.cancelar()); notifyListeners(); } Future guardarTimerSuenoPresetsSegundos(List segundos) async { final normalizados = segundos .where((s) => s > 0) .map((s) => s.clamp(1, const Duration(hours: 23).inSeconds)) .toSet() .toList() ..sort(); _timerSuenoPresetsSegundos = normalizados.isEmpty ? List.from(_timerSuenoPresetsDefecto) : normalizados.take(12).toList(); final prefs = await SharedPreferences.getInstance(); await prefs.setString( _keyTimerSuenoPresets, jsonEncode(_timerSuenoPresetsSegundos), ); notifyListeners(); } Future agregarTimerSuenoPreset(Duration duracion) async { await guardarTimerSuenoPresetsSegundos([ ..._timerSuenoPresetsSegundos, duracion.inSeconds, ]); } Future eliminarTimerSuenoPreset(int segundos) async { await guardarTimerSuenoPresetsSegundos( _timerSuenoPresetsSegundos.where((s) => s != segundos).toList(), ); } Future restaurarTimerSuenoPresets() async { _timerSuenoPresetsSegundos = List.from(_timerSuenoPresetsDefecto); final prefs = await SharedPreferences.getInstance(); await prefs.remove(_keyTimerSuenoPresets); notifyListeners(); } @override void dispose() { _suscripcionEstadoAudio?.cancel(); _suscripcionGrabacion?.cancel(); _errorController.close(); audio.dispose(); unawaited(grabacion.dispose()); timer.dispose(); super.dispose(); } }