Files
pluriwave/lib/estado/estado_radio.dart
T
FreeTLab 5f35ab7d6a
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m27s
feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche.

Los grupos de favoritos no volvian al importar

La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la
asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo
estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar,
que fuerza "sin asignar" a proposito, porque una emisora recien marcada como
favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de
restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se
separa la ruta de restaurar, que respeta el grupo_id del backup y su orden.

Ningun test lo detectaba porque el doble de pruebas era infiel: el fake
conservaba el grupo que la implementacion real destruia, y no tenia
restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo
esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble.

No hace falta subir la version del formato: el envoltorio ya llevaba todo.

El coche perdia la lista al reconectar

El contexto de reproduccion vivia solo en memoria, y el motor que arranca
Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al
reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y
siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se
persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve
contra las listas vivas en cada salto: si el grupo cambia de contenido entre
sesiones, el coche ve lo actual y no una foto vieja.

Cadena de repliegue, decidida por el propietario:
  grupo vivo con la emisora dentro  -> se recorre el grupo
  grupo vivo sin la emisora         -> se permanece en el grupo, primera
  grupo vivo pero vacio             -> se ensancha a favoritos
  grupo borrado                     -> favoritos
  sin favoritos                     -> comportamiento actual

Honrar un grupo de una sola emisora exigia levantar dos barreras, no una:
el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con
menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a
ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora
se salio del grupo" se trata en el flujo del salto.

La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo
solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta
en vez de recorrerse.

Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-02 22:56:10 +02:00

1076 lines
41 KiB
Dart

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<File> 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<File> 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<SharedPreferences> _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<EstadoReproduccion>? _suscripcionEstadoAudio;
Future<void>? _initFuture;
int _revisionReproduccion = 0;
Emisora? _emisoraSeleccionada;
String? _emisoraPreferidaUuid;
AppLocalizations? _l10n;
// Errores de reproducción → SnackBar.
final _errorController = StreamController<String>.broadcast();
Stream<String> get errorStream => _errorController.stream;
List<Emisora> _populares = [];
List<Emisora> _tendencias = [];
List<Emisora> _listaFavoritos = [];
List<GrupoFavoritos> _gruposFavoritos = [];
List<Emisora> _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<Emisora>();
final _memoTendencias = MemoLista<Emisora>();
final _memoFavoritos = MemoLista<Emisora>();
final _memoFavoritosManual = MemoLista<Emisora>();
final _memoGrupos = MemoLista<GrupoFavoritos>();
final _memoCustom = MemoLista<Emisora>();
final _memoInicio = MemoLista<Emisora>();
final _memoDisponibles = MemoLista<Emisora>();
final _memoTimerPresets = MemoLista<int>();
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 = <int>[
180,
300,
600,
900,
1800,
3600,
5400,
7200,
10800,
];
List<int> _timerSuenoPresetsSegundos = List<int>.from(
_timerSuenoPresetsDefecto,
);
OrdenEmisoras _ordenListas = OrdenEmisoras.calidad;
List<Emisora> get populares => _memoPopulares.obtener([
_populares,
_ordenListas,
], () => ordenarEmisoras(_populares, _ordenListas));
List<Emisora> get tendencias => _memoTendencias.obtener([
_tendencias,
_ordenListas,
], () => ordenarEmisoras(_tendencias, _ordenListas));
List<Emisora> 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<Emisora> get listaFavoritosManual => _memoFavoritosManual.obtener([
_listaFavoritos,
], () => List<Emisora>.unmodifiable(_listaFavoritos));
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
_gruposFavoritos,
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
List<Emisora> 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<EstadoReproduccion> get estadoStream => audio.estadoStream;
OrdenEmisoras get ordenListas => _ordenListas;
List<int> get timerSuenoPresetsSegundos => _memoTimerPresets.obtener([
_timerSuenoPresetsSegundos,
], () => List<int>.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<Emisora> get emisorasInicio =>
_memoInicio.obtener([_emisorasCustom, _populares], () {
final mapa = <String, Emisora>{};
for (final emisora in _emisorasCustom) {
mapa[emisora.uuid] = emisora;
}
for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
return mapa.values.toList();
});
List<Emisora> get emisorasDisponiblesPreferencia => _memoDisponibles.obtener(
[
_listaFavoritos,
_emisorasCustom,
_populares,
_tendencias,
busqueda.resultados,
busqueda.cercanas,
],
() {
final mapa = <String, Emisora>{};
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<void> inicializar() {
_initFuture ??= _init();
return _initFuture!;
}
Future<void> _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<void> _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<String, dynamic>;
_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<void> _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<void> 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<void> 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<void> 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<void> crearGrupoFavoritos(String nombre) async {
await favoritos.crearGrupo(nombre);
await cargarGruposFavoritos();
}
Future<void> renombrarGrupoFavoritos(String id, String nombre) async {
await favoritos.renombrarGrupo(id, nombre);
await cargarGruposFavoritos();
}
Future<void> eliminarGrupoFavoritos(String id) async {
await favoritos.eliminarGrupo(id);
await Future.wait([cargarFavoritos(), cargarGruposFavoritos()]);
}
Future<void> 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<void> 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<void> 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<void> 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<void> reproducirEmisoraPreferida() async {
final preferida = emisoraPreferida;
if (preferida == null) return;
await reproducir(preferida);
}
Future<void> _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<num>()
.map((n) => n.toInt())
.where((s) => s > 0)
.toSet()
.toList()
..sort();
if (presets.isNotEmpty) {
_timerSuenoPresetsSegundos = presets.take(12).toList();
}
} catch (_) {
_timerSuenoPresetsSegundos = List<int>.from(_timerSuenoPresetsDefecto);
}
}
Future<void> _cargarEmisoraPreferida() async {
final prefs = await _resolverPrefs();
_emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida);
}
Future<void> _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<void> 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<void> _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<void> 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<void> detenerReproduccion() async {
if (grabacion.activa) {
await grabacion.detener();
}
await audio.detener();
notifyListeners();
}
Future<void> togglePlay() async {
if (audio.estaSonando && grabacion.activa) {
await grabacion.detener();
}
await audio.togglePlay();
notifyListeners();
}
Future<bool> toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora);
if (!esFav) {
await ecualizador.deshabilitarPresetPorEmisora(
emisora.uuid,
notificar: false,
);
}
await cargarFavoritos();
return esFav;
}
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
// ── Emisoras personalizadas ───────────────────────────────────────────────
Future<File> _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<void> _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<Emisora>(
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<String?> _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<void> _ponerEnCuarentena(File archivo) async {
final sidecar = File('${archivo.path}.corrupt');
if (await sidecar.exists()) {
await archivo.delete();
} else {
await archivo.rename(sidecar.path);
}
}
Future<void> _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<void> 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<void> agregarEmitoraCustom(Emisora emisora) =>
agregarEmisoraCustom(emisora);
Future<void> eliminarEmisoraCustom(String uuid) async {
_emisorasCustom = _emisorasCustom.where((e) => e.uuid != uuid).toList();
await _guardarEmisorasCustom();
notifyListeners();
}
// Compatibilidad con el nombre histórico (typo original).
Future<void> 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<Map<String, dynamic>> 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<String, dynamic>
: 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<String> exportarConfigJson() async =>
_exportImport.exportar(await exportarConfig());
/// Parsea un backup JSON; null cuando el contenido no es válido (S4-R4).
Map<String, dynamic>? 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<void> importarConfig(Map<String, dynamic> 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<String, dynamic>.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<String, dynamic>.from(raw as Map));
// `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de
// «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final,
// que es justo lo que la copia trae y hay que conservar. Con `agregar`
// los grupos restaurados arriba volvían como cascarones vacíos y todas
// las emisoras aterrizaban en «Sin asignar».
await favoritos.restaurarFavorito(emisora);
}
// ── Emisoras custom ───────────────────────────────────────────────────
final customRaw = data['emisorasCustom'] as List? ?? [];
_emisorasCustom =
customRaw
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
await _guardarEmisorasCustom();
// ── Ecualizador ───────────────────────────────────────────────────────
final principalRaw = data['presetPrincipalEcualizador'];
final presetPrincipal =
principalRaw is Map
? PresetEcualizador.desdeJson(
Map<String, dynamic>.from(principalRaw),
)
: PresetEcualizador.flat;
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
final presetsPorEmisora = presetsRaw.map<String, PresetEcualizador>(
(uuid, presetJson) => MapEntry(
uuid as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
// v3 extensions: per-device and matrix presets.
Map<String, PresetEcualizador>? presetsDispositivo;
Map<String, PresetEcualizador>? presetsMatriz;
bool? eqMultiDeviceEnabled;
if (version >= 3) {
final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {};
presetsDispositivo = dispositivoRaw.map<String, PresetEcualizador>(
(deviceId, presetJson) => MapEntry(
deviceId as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
final matrizRaw = data['presetsMatriz'] as Map? ?? {};
presetsMatriz = matrizRaw.map<String, PresetEcualizador>(
(clave, presetJson) => MapEntry(
clave as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.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<String, dynamic>) {
// 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<num>().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<void> guardarTimerSuenoPresetsSegundos(List<int> 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<int>.from(_timerSuenoPresetsDefecto)
: normalizados.take(12).toList();
final prefs = await _resolverPrefs();
await prefs.setString(
_keyTimerSuenoPresets,
jsonEncode(_timerSuenoPresetsSegundos),
);
notifyListeners();
}
Future<void> agregarTimerSuenoPreset(Duration duracion) async {
await guardarTimerSuenoPresetsSegundos([
..._timerSuenoPresetsSegundos,
duracion.inSeconds,
]);
}
Future<void> eliminarTimerSuenoPreset(int segundos) async {
await guardarTimerSuenoPresetsSegundos(
_timerSuenoPresetsSegundos.where((s) => s != segundos).toList(),
);
}
Future<void> restaurarTimerSuenoPresets() async {
_timerSuenoPresetsSegundos = List<int>.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();
}
}