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/navegacion_auto.dart'; import '../servicios/persistencia_tolerante.dart'; import '../servicios/servicio_audio.dart'; import '../servicios/servicio_dispositivo_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, ServicioDispositivoAudio? dispositivoAudio, ServicioGrabacionRadio? servicioGrabacion, SharedPreferences? prefs, Future Function()? resolverArchivoCustom, FuenteEmisorasAuto? fuenteAuto, bool iniciarAutomaticamente = true, // iap-freemium-unlock (Design ADR-3): threaded straight through to the // internal `EstadoGrabacion` below — `EstadoRadio` itself has no gated // behavior of its own, but it owns that notifier's construction, so it // inherits the same "required, never defaulted" entitlement contract. required bool Function() esPremium, }) : audio = audio ?? ServicioAudio(), favoritos = favoritos ?? ServicioFavoritos(), radio = radio ?? ServicioRadio(), servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(prefs: prefs), _dispositivoAudio = dispositivoAudio, _prefs = prefs, _resolverArchivoCustom = resolverArchivoCustom, _fuenteAuto = fuenteAuto { ecualizador = EstadoEcualizador( audio: this.audio, servicio: this.servicioEcualizador, dispositivoAudio: _dispositivoAudio, emisoraActualUuid: () => emisoraActual?.uuid, ); grabacion = EstadoGrabacion( servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs), emisoraActual: () => emisoraActual, alError: _errorController.add, esPremium: esPremium, ); 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; /// Optional device service — null means no device subscription established. /// Wired in production from app.dart; defaults to null in tests. final ServicioDispositivoAudio? _dispositivoAudio; /// 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; /// Android Auto browse source (Design "live snapshot the source /// prefers"). Optional and unused by default — wired from main.dart via /// the [FuenteEmisorasAutoLocal] instance registered into the handler. /// When set, this instance's in-memory lists are pushed on every mutation /// so the car sees the same data as the phone without a duplicate read. final FuenteEmisorasAuto? _fuenteAuto; /// 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 = []; // persistence-resilience (D5): set when the custom-stations file EXISTS // but could not be READ at the OS level (vs. a parse failure, which // quarantines the file instead -- see _ponerEnCuarentena). While true, // _guardarEmisorasCustom is suppressed for the rest of this session: we // cannot tell whether the underlying file is actually intact, so we never // risk clobbering it with an empty in-memory list. Unlike the Alarms // degraded flag, this is intentionally NOT cleared by an explicit // add/remove -- only the NEXT clean/partial load in _cargarEmisorasCustom // clears it (D5's asymmetry: a transiently-unreadable file may still be // intact on disk). bool _customDegradado = false; 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 _memoFavoritosManual = 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'; // Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero // keeps showing "what I was listening to" (stopped, not playing) after a // full app restart instead of going empty. static const _keyUltimaEmisora = 'ultima_emisora_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)); /// WU4, `favorites-organization` spec: Favoritos' own manual order — /// unlike [listaFavoritos], this is NOT re-sorted by the global /// [ordenListas] setting on every read. It reflects exactly the order /// stored via the `orden` column (`ServicioFavoritos.obtenerTodos()`), /// which [reordenarFavorito] and [ordenarFavoritos] mutate. Other /// consumers of `listaFavoritos` (Android Auto, the Escuchar grid) are /// unaffected by drag-reordering Favoritos. List get listaFavoritosManual => _memoFavoritosManual.obtener([ _listaFavoritos, ], () => List.unmodifiable(_listaFavoritos)); 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(); await _restaurarUltimaEmisora(); } /// Issue 4 (feedback-pruebas): restores the last-played station as a /// STOPPED `emisoraActual` on a cold start. Only fills the gap — if /// something is ALREADY selected (a real play already ran concurrently), /// this is a no-op. Never touches `audio`: no playback starts, no network /// request is made, `estadoStream`/`estaSonando` stay at their fresh /// "detenido" default, exactly like every other consumer of /// `emisoraActual` already expects (they gate "is it playing" on the /// separate playback-status stream, never on `emisoraActual != null`). Future _restaurarUltimaEmisora() async { if (_emisoraSeleccionada != null || audio.emisoraActual != null) return; try { final prefs = await _resolverPrefs(); final raw = prefs.getString(_keyUltimaEmisora); if (raw == null) return; final mapa = jsonDecode(raw) as Map; _emisoraSeleccionada = Emisora.fromMap(mapa); } catch (e) { registrarSaltoPersistencia( subsistema: 'ultima_emisora', detalle: 'restaurar', razon: e.toString(), ); } } /// Best-effort remembers [emisora] as the last used station (issue 4) so /// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and- /// forget, same treatment [reproducir] already gives other non-critical /// side effects (e.g. `radio.registrarClick`) — a failed write here must /// never block or fail actual playback. Future _persistirUltimaEmisora(Emisora emisora) async { try { final prefs = await _resolverPrefs(); await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap())); } catch (e) { registrarSaltoPersistencia( subsistema: 'ultima_emisora', detalle: 'persistir ${emisora.uuid}', razon: e.toString(), ); } } /// 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()); } // Design "playback coherence with EstadoRadio": a car-initiated // selection (Android Auto's playFromMediaId) changes audio.emisoraActual // directly, bypassing reproducir(). Without this, _emisoraSeleccionada // would keep shadowing the car's station on emisoraActual's getter. final actual = audio.emisoraActual; if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) { _emisoraSeleccionada = actual; // Issue 4: an Android-Auto-initiated selection is a real station // change too — remember it the same way `reproducir` does. unawaited(_persistirUltimaEmisora(actual)); } 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; // Design "live snapshot the source prefers": Android Auto's `Todas` // folder mirrors the same populares list the phone just loaded. // // Fix `android-auto-orden`: pushes the SORTED [populares] getter, not // the raw [_populares] field — the same [_ordenListas] setting the // phone's own discovery lists (e.g. Buscar's `tendencias`) already // sort by must also govern this folder's order, not the API's raw // arrival order. `navegacion_auto.dart`'s `hijos()` no longer // re-sorts, so whatever order arrives here IS what the driver sees. _fuenteAuto?.actualizarSnapshot(todas: populares); notifyListeners(); } } Future cargarFavoritos() async { _listaFavoritos = await favoritos.obtenerTodos(); await _normalizarEmisoraPreferida(); // Fix `android-auto-orden`: pushes the documented manual-order accessor // explicitly. [listaFavoritosManual] is backed by the same list as // [_listaFavoritos] today (obtenerTodos() already returns the persisted // manual order), but naming the intent here — "the exact order the // Favoritos screen shows and reorders" — keeps this call from silently // drifting onto a re-sorted list in a future refactor. _fuenteAuto?.actualizarSnapshot(favoritos: listaFavoritosManual); notifyListeners(); } Future cargarGruposFavoritos() async { _gruposFavoritos = await favoritos.obtenerGrupos(); // Design "live snapshot the source prefers": Android Auto's grouped // Favoritos tree mirrors the same group list the phone just loaded. _fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos); 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(); } /// WU4: persists a single drag-to-reorder move within [listaFavoritosManual]. /// [nuevoIndice] is the absolute target position among ALL favorites (not /// scoped to any chip filter) — the screen translates a filtered-list drag /// into this global index before calling this method. Future reordenarFavorito(String uuid, int nuevoIndice) async { await favoritos.reordenar(uuid, nuevoIndice); await cargarFavoritos(); } /// WU4: the Favoritos `swap_vert` sort action. Reuses the existing /// [OrdenEmisoras] criteria and [ordenarEmisoras] function — "It MUST NOT /// introduce a sort criterion with no backing implementation" /// (favorites-organization spec). Unlike [ordenListas] (a GLOBAL setting /// affecting favorites/searches/nearby/quick-lists), this sorts ONLY the /// favorites list once and PERSISTS the result as the new manual order — /// consistent with [reordenarFavorito]'s "the order MUST persist" contract. Future ordenarFavoritos(OrdenEmisoras criterio) async { final ordenados = ordenarEmisoras(_listaFavoritos, criterio); for (var i = 0; i < ordenados.length; i++) { await favoritos.reordenar(ordenados[i].uuid, i); } 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, 'popularidad' => OrdenEmisoras.popularidad, _ => 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(); // Fix `android-auto-orden`: Todas/Mis emisoras' Android Auto order is // derived from this same setting (see cargarPopulares/ // _cargarEmisorasCustom above) — without an immediate re-push, a live // car session would keep showing the OLD order until the next full // reload instead of updating right away, same as the phone does via // this method's own memoized getters. _fuenteAuto?.actualizarSnapshot( todas: populares, misEmisoras: emisorasCustom, ); 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(); // Issue 4: remembers the station the user just picked so it survives a // restart — fire-and-forget, same treatment as `radio.registrarClick` // below (a persistence failure here must never block playback). unawaited(_persistirUltimaEmisora(emisora)); 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'); } /// Loads the custom-stations file with per-entry tolerance /// (persistence-resilience D1/D5). Two DIFFERENT failure kinds get TWO /// different treatments because they carry different guarantees about /// whether the file itself is still intact: /// - the file cannot be READ at the OS level (see [_leerContenidoCustom]) /// -> IO-fail: unknown whether the file is intact, so it is left /// completely untouched and [_customDegradado] suppresses writes. /// - the file reads fine but its top-level JSON/shape is invalid -> the /// bytes we DID manage to read are definitely the corrupt culprit, so /// they are quarantined into a `.corrupt` sidecar and the live path is /// cleared for the next write ([_ponerEnCuarentena]); no flag needed, /// the cleared path is itself the "authority restored" signal. /// - per-entry failures inside an otherwise-valid list are handled by the /// shared [parseListaTolerante] (D1): survivors are kept, no flag/ /// quarantine at all. Future _cargarEmisorasCustom() async { // Resolving the path itself is part of the IO surface: a throw here must // get the same IO-fail treatment as an unreadable file, not escape into // _init()'s Future.wait and take the sibling loads down with it. final File archivo; try { archivo = await _archivoCustom(); } catch (e) { _customDegradado = true; registrarSaltoPersistencia( subsistema: 'emisoras_custom', detalle: 'resolucion de ruta', razon: e.toString(), ); // Fix `android-auto-orden`: pushes the SORTED [emisorasCustom] getter // (see the doc on this method's other 3 identical call sites below). _fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom); notifyListeners(); return; } final contenido = await _leerContenidoCustom(archivo); if (contenido == null) return; // ya resuelto: vacio o degradado por IO. try { final data = jsonDecode(contenido) as List; final resultado = parseListaTolerante( data, Emisora.fromMap, subsistema: 'emisoras_custom', coleccion: 'emisoras_custom', ); _emisorasCustom = resultado.validas; _customDegradado = false; } catch (e) { await _ponerEnCuarentena(archivo); _emisorasCustom = []; registrarSaltoPersistencia( subsistema: 'emisoras_custom', detalle: archivo.path, razon: e.toString(), ); } // Fix `android-auto-orden`: sorted getter, not the raw field. _fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom); notifyListeners(); } /// Reads the raw content of [archivo]; returns null when the outcome was /// already fully resolved here, so the caller has nothing left to parse: /// - the file does not exist -> healthy empty state (unchanged behavior). /// - `exists()`/`readAsString()` throws -> IO-fail (D5): the file is left /// untouched (we cannot know if it is actually intact) and /// [_customDegradado] suppresses [_guardarEmisorasCustom] for the rest /// of this session. Future _leerContenidoCustom(File archivo) async { try { if (!await archivo.exists()) { _emisorasCustom = []; _customDegradado = false; // Fix `android-auto-orden`: sorted getter, not the raw field. _fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom); notifyListeners(); return null; } return await archivo.readAsString(); } catch (e) { _emisorasCustom = []; _customDegradado = true; registrarSaltoPersistencia( subsistema: 'emisoras_custom', detalle: archivo.path, razon: e.toString(), ); // Fix `android-auto-orden`: sorted getter, not the raw field. _fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom); notifyListeners(); return null; } } /// Moves an unparseable custom-stations file out of the live path so the /// next add/remove starts fresh. If a `.corrupt` sidecar from a PREVIOUS /// quarantine already exists, that earlier payload is preserved untouched /// and the newly-corrupt live file is simply dropped (D5) -- a sidecar /// only ever holds the OLDEST unresolved quarantine, never overwritten by /// a newer one. Future _ponerEnCuarentena(File archivo) async { final sidecar = File('${archivo.path}.corrupt'); if (await sidecar.exists()) { await archivo.delete(); } else { await archivo.rename(sidecar.path); } } Future _guardarEmisorasCustom() async { // persistence-resilience (D5): never write while an IO-degraded read // means we cannot be sure the on-disk file is still intact. if (_customDegradado) return; 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 (v4 — portabilidad completa /// con presets por dispositivo, matriz multi-device y el toggle /// on/off del ecualizador). /// La forma del sobre 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, // v3 extensions — multi-device EQ fields. presetsPorDispositivo: ecualizador.presetsDispositivo, presetsMatriz: ecualizador.presetsMatriz, eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled, // v4 extension — equalizer global on/off toggle. ecualizadorActivo: ecualizador.activo, ); } /// 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), v2 (portabilidad completa), /// v3 (+ presets por dispositivo, presets matriz, toggle multi-device) /// y v4 (+ toggle on/off del ecualizador). Future importarConfig(Map data) async { final version = data['version'] as int? ?? 1; if (version > 4) 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), ), ), ); // v3 extensions: per-device and matrix presets. Map? presetsDispositivo; Map? presetsMatriz; bool? eqMultiDeviceEnabled; if (version >= 3) { final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {}; presetsDispositivo = dispositivoRaw.map( (deviceId, presetJson) => MapEntry( deviceId as String, PresetEcualizador.desdeJson( Map.from(presetJson as Map), ), ), ); final matrizRaw = data['presetsMatriz'] as Map? ?? {}; presetsMatriz = matrizRaw.map( (clave, presetJson) => MapEntry( clave as String, PresetEcualizador.desdeJson( Map.from(presetJson as Map), ), ), ); eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?; } // v4 extension: equalizer on/off toggle. Read unconditionally — the key // is simply absent on any pre-v4 backup, which resolves to `null` and // leaves the user's CURRENT toggle untouched (see // `EstadoEcualizador.importarConfiguracion` doc): an old backup must // never flip a live setting it never carried. final ecualizadorActivo = data['ecualizadorActivo'] as bool?; await ecualizador.importarConfiguracion( principal: presetPrincipal, porEmisora: presetsPorEmisora, presetsDispositivo: presetsDispositivo, presetsMatriz: presetsMatriz, eqMultiDeviceEnabled: eqMultiDeviceEnabled, activo: ecualizadorActivo, ); // ── Alarmas (v2) ────────────────────────────────────────────────────── if (version >= 2) { final alarmasData = data['alarmas']; if (alarmasData is Map) { // Escribimos el bloque JSON tal como estaba en el dispositivo origen. // EstadoAlarmas es un ChangeNotifier independiente y de larga vida // que ya cargó sus alarmas en memoria: NO relee este storage por sí // solo. El llamador (pantalla_ajustes_backup.dart) es responsable de // invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido // de `refrescarProgramacion()` tras un import exitoso; EstadoRadio // se mantiene deliberadamente sin depender de EstadoAlarmas. 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, 'popularidad' => OrdenEmisoras.popularidad, _ => 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(); _dispositivoAudio?.dispose(); super.dispose(); } }