import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart' show Locale; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.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 'estado_busqueda.dart'; import 'estado_ecualizador.dart'; import 'estado_grabacion.dart'; import 'orden_emisoras.dart'; import '../servicios/servicio_audio.dart'; import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_export_import.dart'; import '../servicios/servicio_favoritos.dart'; import '../servicios/servicio_grabacion_radio.dart'; import '../servicios/servicio_radio.dart'; import '../servicios/servicio_timer.dart'; export 'orden_emisoras.dart' show OrdenEmisoras; /// Estado global de la app con ChangeNotifier (Provider). /// /// S4 end-state: playback + stations + favorites orchestration. EQ, recording /// and search state live in their own notifiers (EstadoEcualizador, /// EstadoGrabacion, EstadoBusqueda) created here during the S4 transition and /// exposed app-wide through ListenableProviders in app.dart. class EstadoRadio extends ChangeNotifier { EstadoRadio({ ServicioAudio? audio, ServicioFavoritos? favoritos, ServicioRadio? radio, ServicioEcualizador? servicioEcualizador, ServicioGrabacionRadio? servicioGrabacion, SharedPreferences? prefs, Future Function()? resolverArchivoCustom, bool iniciarAutomaticamente = true, }) : audio = audio ?? ServicioAudio(), favoritos = favoritos ?? ServicioFavoritos(), radio = radio ?? ServicioRadio(), servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(prefs: prefs), _prefs = prefs, _resolverArchivoCustom = resolverArchivoCustom { ecualizador = EstadoEcualizador( audio: this.audio, servicio: this.servicioEcualizador, emisoraActualUuid: () => emisoraActual?.uuid, ); grabacion = EstadoGrabacion( servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs), emisoraActual: () => emisoraActual, alError: _errorController.add, ); busqueda = EstadoBusqueda( radio: this.radio, ordenListas: () => _ordenListas, textos: () => _textos, alError: _errorController.add, ); timer = ServicioTimer(this.audio); _escucharErroresReproduccion(); if (iniciarAutomaticamente) { _initFuture = _init(); } } final ServicioAudio audio; final ServicioFavoritos favoritos; final ServicioRadio radio; final ServicioEcualizador servicioEcualizador; /// Domain notifiers extracted from this class (S4). Created and disposed /// here (they need EstadoRadio's services and callbacks at construction); /// exposed app-wide through ListenableProviders in app.dart. late final EstadoEcualizador ecualizador; late final EstadoGrabacion grabacion; late final EstadoBusqueda busqueda; static const ServicioExportImport _exportImport = ServicioExportImport(); final SharedPreferences? _prefs; final Future Function()? _resolverArchivoCustom; /// Single startup instance injected from main() (S3-R4); falls back to /// getInstance() only when nothing was injected (tests, legacy callers). Future _resolverPrefs() async => _prefs ?? SharedPreferences.getInstance(); 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); // The alarm bridge gets its localizations through // EstadoAlarmas.configurarLocalizaciones (Decision 3.2) — the old // static ServicioAlarmasAndroid shim is gone. } late final ServicioTimer timer; StreamSubscription? _suscripcionEstadoAudio; 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 _listaFavoritos = []; List _gruposFavoritos = []; List _emisorasCustom = []; bool _cargandoPopulares = false; String? _errorCarga; // Identity-memoized derived lists so `context.select` consumers only // rebuild when the underlying data actually changes (S4-R5). final _memoPopulares = MemoLista(); final _memoTendencias = MemoLista(); final _memoFavoritos = MemoLista(); final _memoGrupos = MemoLista(); final _memoCustom = MemoLista(); final _memoInicio = MemoLista(); final _memoDisponibles = MemoLista(); final _memoTimerPresets = MemoLista(); 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 => _memoPopulares.obtener([ _populares, _ordenListas, ], () => ordenarEmisoras(_populares, _ordenListas)); List get tendencias => _memoTendencias.obtener([ _tendencias, _ordenListas, ], () => ordenarEmisoras(_tendencias, _ordenListas)); List get listaFavoritos => _memoFavoritos.obtener([ _listaFavoritos, _ordenListas, ], () => ordenarEmisoras(_listaFavoritos, _ordenListas)); List get gruposFavoritos => _memoGrupos.obtener([ _gruposFavoritos, ], () => List.unmodifiable(_gruposFavoritos)); List get emisorasCustom => _memoCustom.obtener([ _emisorasCustom, _ordenListas, ], () => ordenarEmisoras(_emisorasCustom, _ordenListas)); bool get cargandoPopulares => _cargandoPopulares; String? get error => _errorCarga; Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual; Emisora? get emisoraPreferida => _resolverEmisoraPreferida(); String? get emisoraPreferidaUuid => emisoraPreferida?.uuid; Stream get estadoStream => audio.estadoStream; OrdenEmisoras get ordenListas => _ordenListas; List get timerSuenoPresetsSegundos => _memoTimerPresets.obtener([ _timerSuenoPresetsSegundos, ], () => List.unmodifiable(_timerSuenoPresetsSegundos)); bool get emisoraActualEsFavorita { final actual = emisoraActual; if (actual == null) return false; return _listaFavoritos.any((e) => e.uuid == actual.uuid); } /// Lista principal (home): custom + populares, sin duplicados. List get emisorasInicio => _memoInicio.obtener([_emisorasCustom, _populares], () { 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 => _memoDisponibles.obtener( [ _listaFavoritos, _emisorasCustom, _populares, _tendencias, busqueda.resultados, busqueda.cercanas, ], () { 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 busqueda.resultados) { mapa.putIfAbsent(emisora.uuid, () => emisora); } for (final emisora in busqueda.cercanas) { mapa.putIfAbsent(emisora.uuid, () => emisora); } return mapa.values.toList(); }, ); Future inicializar() { _initFuture ??= _init(); return _initFuture!; } Future _init() async { await grabacion.inicializar(); await ecualizador.cargarPersistido(); 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.activa) { unawaited(grabacion.detener()); } notifyListeners(); }); } 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 _resolverPrefs(); 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 _resolverPrefs(); 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 _resolverPrefs(); _emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida); } Future _cargarOrdenListas() async { final prefs = await _resolverPrefs(); 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 _resolverPrefs(); await prefs.setString(_keyOrdenListas, orden.name); // Search owns its own listeners (S4-R3) but sorts with this preference. busqueda.notificarCambioOrden(); notifyListeners(); } Future _normalizarEmisoraPreferida() async { final preferida = _resolverEmisoraPreferida(); if (preferida?.uuid == _emisoraPreferidaUuid) return; _emisoraPreferidaUuid = preferida?.uuid; final prefs = await _resolverPrefs(); 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; } Future reproducir(Emisora emisora) async { final revision = ++_revisionReproduccion; if (grabacion.activa) { await grabacion.detener(); } _emisoraSeleccionada = emisora; notifyListeners(); try { await audio.reproducir(emisora); if (revision != _revisionReproduccion) return; unawaited(radio.registrarClick(emisora.uuid)); await ecualizador.aplicarPresetActivo( ecualizador.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 detenerReproduccion() async { if (grabacion.activa) { await grabacion.detener(); } await audio.detener(); notifyListeners(); } Future togglePlay() async { if (audio.estaSonando && grabacion.activa) { await grabacion.detener(); } await audio.togglePlay(); notifyListeners(); } Future toggleFavorito(Emisora emisora) async { final esFav = await favoritos.toggleFavorito(emisora); if (!esFav) { await ecualizador.deshabilitarPresetPorEmisora( emisora.uuid, notificar: false, ); } await cargarFavoritos(); return esFav; } Future esFavorito(String uuid) => favoritos.esFavorito(uuid); // ── 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 { // Reassign (not mutate) so identity-memoized views refresh (S4-R5). _emisorasCustom = [ ..._emisorasCustom.where((e) => e.uuid != emisora.uuid), emisora, ]; await _guardarEmisorasCustom(); notifyListeners(); } // Compatibilidad con el nombre histórico (typo original). Future agregarEmitoraCustom(Emisora emisora) => agregarEmisoraCustom(emisora); Future eliminarEmisoraCustom(String uuid) async { _emisorasCustom = _emisorasCustom.where((e) => e.uuid != uuid).toList(); 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). /// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4). Future> exportarConfig() async { final favs = await favoritos.obtenerTodos(); final grupos = await favoritos.obtenerGrupos(); final prefs = await _resolverPrefs(); // 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 _exportImport.construirExportacion( gruposFavoritos: grupos, favoritos: favs, emisorasCustom: _emisorasCustom, presetPrincipal: ecualizador.presetPrincipal, presetsPorEmisora: ecualizador.presetsPorEmisora, alarmas: alarmasData, emisoraPreferidaUuid: _emisoraPreferidaUuid, ordenListas: _ordenListas.name, timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos, ); } /// Exportación lista para compartir como archivo (JSON con indentación). Future exportarConfigJson() async => _exportImport.exportar(await exportarConfig()); /// Parsea un backup JSON; null cuando el contenido no es válido (S4-R4). Map? parsearConfigJson(String raw) => _exportImport.importar(raw); /// 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 _resolverPrefs(); // ── 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']; final presetPrincipal = principalRaw is Map ? PresetEcualizador.desdeJson( Map.from(principalRaw), ) : PresetEcualizador.flat; final presetsRaw = data['presetsEcualizador'] as Map? ?? {}; final presetsPorEmisora = presetsRaw.map( (uuid, presetJson) => MapEntry( uuid as String, PresetEcualizador.desdeJson( Map.from(presetJson as Map), ), ), ); await ecualizador.importarConfiguracion( principal: presetPrincipal, porEmisora: presetsPorEmisora, ); // ── 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 _resolverPrefs(); 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 _resolverPrefs(); await prefs.remove(_keyTimerSuenoPresets); notifyListeners(); } @override void dispose() { _suscripcionEstadoAudio?.cancel(); _errorController.close(); ecualizador.dispose(); busqueda.dispose(); grabacion.dispose(); audio.dispose(); timer.dispose(); super.dispose(); } }