From 0416b301b2fbc9370b84b60cd33ea4268ee65814 Mon Sep 17 00:00:00 2001 From: FreeTLab Date: Thu, 11 Jun 2026 21:16:30 +0200 Subject: [PATCH] refactor(state): extract export/import service and equalizer state from EstadoRadio - New ServicioExportImport owns the v2 backup envelope, pretty JSON encode and graceful decode; byte-compatible with existing exports, locked by a round-trip test - pantalla_ajustes delegates backup serialization to the service (inline jsonDecode/jsonEncode removed) - New EstadoEcualizador ChangeNotifier owns all EQ state and persistence (principal/current/per-station presets, active flag), exposed via its own provider so EQ changes no longer rebuild EstadoRadio consumers - EstadoRadio slims down ~210 lines and keeps 15 delegating compat members marked TODO(S4b) for the next slice to remove - Player EQ toggle rewired to the new provider to avoid going stale - 4 new tests (103 total green), flutter analyze clean --- lib/app.dart | 7 + lib/estado/estado_ecualizador.dart | 207 ++++++++++++++ lib/estado/estado_radio.dart | 268 ++++++------------ lib/pantallas/pantalla_ajustes.dart | 56 ++-- lib/pantallas/pantalla_reproductor.dart | 19 +- lib/servicios/servicio_export_import.dart | 80 ++++++ .../apply-progress.md | 73 ++++- .../app-quality-and-native-alarms/tasks.md | 24 +- test/estado/estado_ecualizador_test.dart | 51 ++++ .../servicio_export_import_test.dart | 83 ++++++ 10 files changed, 637 insertions(+), 231 deletions(-) create mode 100644 lib/estado/estado_ecualizador.dart create mode 100644 lib/servicios/servicio_export_import.dart create mode 100644 test/estado/estado_ecualizador_test.dart create mode 100644 test/servicios/servicio_export_import_test.dart diff --git a/lib/app.dart b/lib/app.dart index 4976b7e..ab91d84 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'estado/estado_ecualizador.dart'; import 'estado/estado_radio.dart'; import 'estado/estado_alarmas.dart'; import 'estado/estado_idioma.dart'; @@ -35,6 +36,12 @@ class PluriWaveApp extends StatelessWidget { return MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)), + // EQ notifier (S4-R1). Created and disposed by EstadoRadio during + // the S4 transition; this provider only exposes the instance, so it + // declares no dispose callback. + ListenableProvider( + create: (context) => context.read().ecualizador, + ), ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)), ChangeNotifierProvider( create: (_) => EstadoIdioma(sharedPreferences: prefs), diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart new file mode 100644 index 0000000..5050c8d --- /dev/null +++ b/lib/estado/estado_ecualizador.dart @@ -0,0 +1,207 @@ +import 'package:flutter/foundation.dart'; + +import '../modelos/preset_ecualizador.dart'; +import '../servicios/servicio_audio.dart'; +import '../servicios/servicio_ecualizador.dart'; + +/// Equalizer state extracted from `EstadoRadio` (S4-R1). +/// +/// Owns the main preset, the per-station preset map, the current (applied) +/// preset and the enabled flag, plus their persistence through +/// [ServicioEcualizador] and their application through [ServicioAudio]. +/// Notifies ONLY its own listeners — EQ changes must not rebuild +/// `EstadoRadio` consumers (S4-R1-A, S4-R5). +class EstadoEcualizador extends ChangeNotifier { + EstadoEcualizador({ + required this.audio, + ServicioEcualizador? servicio, + String? Function()? emisoraActualUuid, + }) : servicio = servicio ?? ServicioEcualizador(), + _emisoraActualUuid = emisoraActualUuid ?? (() => null); + + final ServicioAudio audio; + final ServicioEcualizador servicio; + + /// Callback into the owner (EstadoRadio) for the currently playing station; + /// keeps this notifier free of any station-list coupling. + final String? Function() _emisoraActualUuid; + + final Map _presetsEmisoraMap = {}; + PresetEcualizador _presetPrincipal = PresetEcualizador.flat; + PresetEcualizador _presetActual = PresetEcualizador.flat; + bool _activo = true; + + PresetEcualizador get presetActual => _presetActual; + PresetEcualizador get presetPrincipal => _presetPrincipal; + bool get activo => _activo; + bool get disponible => audio.ecualizadorDisponible; + Map get presetsPorEmisora => + Map.unmodifiable(_presetsEmisoraMap); + + bool get emisoraActualTienePresetPropio { + final uuid = _emisoraActualUuid(); + if (uuid == null) return false; + return tienePresetPorEmisora(uuid); + } + + bool tienePresetPorEmisora(String uuid) => + _presetsEmisoraMap.containsKey(uuid); + + PresetEcualizador? presetPorEmisora(String uuid) => _presetsEmisoraMap[uuid]; + + PresetEcualizador presetParaEmisora(String uuid) => + _presetsEmisoraMap[uuid] ?? _presetPrincipal; + + /// Loads the persisted EQ configuration and applies it to the audio engine. + Future cargarPersistido() async { + try { + final config = await servicio.cargar(); + _presetPrincipal = config.principal; + _presetActual = config.principal; + _activo = config.activo; + _presetsEmisoraMap + ..clear() + ..addAll(config.porEmisora); + await audio.setEcualizadorActivo(_activo); + await audio.aplicarPreset(_presetPrincipal); + } catch (_) { + _presetPrincipal = PresetEcualizador.flat; + _presetActual = PresetEcualizador.flat; + _activo = true; + _presetsEmisoraMap.clear(); + } + } + + /// Applies [preset] to the audio engine and tracks it as current + /// WITHOUT persisting it (used when switching stations). + Future aplicarPresetActivo(PresetEcualizador preset) async { + _presetActual = preset; + await audio.aplicarPreset(preset); + } + + Future cambiarPresetPrincipal( + PresetEcualizador preset, { + bool notificar = true, + }) async { + _presetPrincipal = preset; + await servicio.guardarPrincipal(preset); + + final uuid = _emisoraActualUuid(); + final puedeAplicarAhora = + uuid == null || !_presetsEmisoraMap.containsKey(uuid); + if (puedeAplicarAhora) { + await aplicarPresetActivo(preset); + } + + if (notificar) notifyListeners(); + } + + Future guardarPresetPorEmisora( + String uuid, + PresetEcualizador preset, { + bool notificar = true, + }) async { + _presetsEmisoraMap[uuid] = preset; + await servicio.guardarPorEmisora(uuid, preset); + if (_emisoraActualUuid() == uuid) { + await aplicarPresetActivo(preset); + } + if (notificar) notifyListeners(); + } + + Future habilitarPresetPorEmisora( + String uuid, { + PresetEcualizador? base, + bool notificar = true, + }) async { + final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal; + await guardarPresetPorEmisora(uuid, presetBase, notificar: notificar); + } + + Future deshabilitarPresetPorEmisora( + String uuid, { + bool notificar = true, + }) async { + _presetsEmisoraMap.remove(uuid); + await servicio.eliminarPorEmisora(uuid); + if (_emisoraActualUuid() == uuid) { + await aplicarPresetActivo(_presetPrincipal); + } + if (notificar) notifyListeners(); + } + + Future cambiarModoEmisoraActual({required bool usarPropio}) async { + final uuid = _emisoraActualUuid(); + if (uuid == null) return; + if (usarPropio) { + await habilitarPresetPorEmisora(uuid); + } else { + await deshabilitarPresetPorEmisora(uuid); + } + } + + Future cambiarActivo(bool activo) async { + _activo = activo; + await servicio.guardarActivo(activo); + await audio.setEcualizadorActivo(activo); + if (activo) { + await audio.aplicarPreset(_presetActual); + } + notifyListeners(); + } + + Future cambiarPreset( + PresetEcualizador preset, { + bool guardarPorEmisora = true, + }) async { + final uuid = _emisoraActualUuid(); + final usarPresetPropio = + guardarPorEmisora && + uuid != null && + _presetsEmisoraMap.containsKey(uuid); + + if (usarPresetPropio) { + await guardarPresetPorEmisora(uuid, preset); + return; + } + await cambiarPresetPrincipal(preset); + } + + Future cambiarBanda(int index, double db) async { + final bandas = List.from(_presetActual.bandas); + if (index < 0 || index >= bandas.length) return; + + bandas[index] = db; + final modificado = PresetEcualizador( + nombre: 'Personalizado', + bandas: bandas, + ); + await cambiarPreset(modificado); + } + + /// Replaces the whole EQ configuration (backup import path): persists it, + /// re-applies the preset effective for the current station and notifies. + Future importarConfiguracion({ + required PresetEcualizador principal, + required Map porEmisora, + }) async { + _presetPrincipal = principal; + _presetsEmisoraMap + ..clear() + ..addAll(porEmisora); + + await servicio.guardarConfiguracion( + ConfiguracionEcualizador( + principal: _presetPrincipal, + porEmisora: _presetsEmisoraMap, + activo: _activo, + ), + ); + + final uuid = _emisoraActualUuid(); + await aplicarPresetActivo( + uuid == null ? _presetPrincipal : presetParaEmisora(uuid), + ); + notifyListeners(); + } +} diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 1ec80e2..45a6263 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -16,8 +16,10 @@ import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/preset_ecualizador.dart'; +import 'estado_ecualizador.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'; @@ -48,6 +50,11 @@ class EstadoRadio extends ChangeNotifier { grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs), _prefs = prefs, _resolverArchivoCustom = resolverArchivoCustom { + ecualizador = EstadoEcualizador( + audio: this.audio, + servicio: this.servicioEcualizador, + emisoraActualUuid: () => emisoraActual?.uuid, + ); timer = ServicioTimer(this.audio); _escucharErroresReproduccion(); _escucharGrabacion(); @@ -60,7 +67,13 @@ class EstadoRadio extends ChangeNotifier { final ServicioFavoritos favoritos; final ServicioRadio radio; final ServicioEcualizador servicioEcualizador; + + /// EQ state extracted to its own notifier (S4-R1). Owned (and disposed) + /// by EstadoRadio during the S4 transition; exposed app-wide through a + /// ListenableProvider in app.dart. + late final EstadoEcualizador ecualizador; final ServicioGrabacionRadio grabacion; + static const ServicioExportImport _exportImport = ServicioExportImport(); final SharedPreferences? _prefs; final Future Function()? _resolverArchivoCustom; @@ -105,12 +118,6 @@ class EstadoRadio extends ChangeNotifier { List _gruposFavoritos = []; List _emisorasCustom = []; - // Presets EQ guardados por uuid de emisora. - final Map _presetsEmisoraMap = {}; - PresetEcualizador _presetPrincipal = PresetEcualizador.flat; - PresetEcualizador _presetActual = PresetEcualizador.flat; - bool _ecualizadorActivo = true; - bool _cargandoPopulares = false; bool _cargandoBusqueda = false; bool _cargandoMasBusqueda = false; @@ -164,10 +171,15 @@ class EstadoRadio extends ChangeNotifier { Emisora? get emisoraPreferida => _resolverEmisoraPreferida(); String? get emisoraPreferidaUuid => emisoraPreferida?.uuid; Stream get estadoStream => audio.estadoStream; - PresetEcualizador get presetEcualizador => _presetActual; - PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal; - bool get ecualizadorActivo => _ecualizadorActivo; - bool get ecualizadorDisponible => audio.ecualizadorDisponible; + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. + PresetEcualizador get presetEcualizador => ecualizador.presetActual; + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. + PresetEcualizador get presetPrincipalEcualizador => + ecualizador.presetPrincipal; + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. + bool get ecualizadorActivo => ecualizador.activo; + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. + bool get ecualizadorDisponible => ecualizador.disponible; OrdenEmisoras get ordenListas => _ordenListas; List get timerSuenoPresetsSegundos => List.unmodifiable(_timerSuenoPresetsSegundos); @@ -178,11 +190,9 @@ class EstadoRadio extends ChangeNotifier { return _listaFavoritos.any((e) => e.uuid == actual.uuid); } - bool get emisoraActualTienePresetPropio { - final actual = emisoraActual; - if (actual == null) return false; - return tienePresetEcualizadorPorEmisora(actual.uuid); - } + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. + bool get emisoraActualTienePresetPropio => + ecualizador.emisoraActualTienePresetPropio; EstadoGrabacionRadio get estadoGrabacion => grabacion.estado; bool get grabacionActiva => grabacion.estado.activa; @@ -232,7 +242,7 @@ class EstadoRadio extends ChangeNotifier { Future _init() async { await grabacion.inicializar(); - await _cargarEcualizadorPersistido(); + await ecualizador.cargarPersistido(); await _cargarOrdenListas(); await _cargarEmisoraPreferida(); await _cargarTimerSuenoPresets(); @@ -271,25 +281,6 @@ class EstadoRadio extends ChangeNotifier { }); } - Future _cargarEcualizadorPersistido() async { - try { - final config = await servicioEcualizador.cargar(); - _presetPrincipal = config.principal; - _presetActual = config.principal; - _ecualizadorActivo = config.activo; - _presetsEmisoraMap - ..clear() - ..addAll(config.porEmisora); - await audio.setEcualizadorActivo(_ecualizadorActivo); - await audio.aplicarPreset(_presetPrincipal); - } catch (_) { - _presetPrincipal = PresetEcualizador.flat; - _presetActual = PresetEcualizador.flat; - _ecualizadorActivo = true; - _presetsEmisoraMap.clear(); - } - } - Future cargarPopulares() async { _cargandoPopulares = true; _errorCarga = null; @@ -609,7 +600,9 @@ class EstadoRadio extends ChangeNotifier { await audio.reproducir(emisora); if (revision != _revisionReproduccion) return; unawaited(radio.registrarClick(emisora.uuid)); - await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid)); + await ecualizador.aplicarPresetActivo( + ecualizador.presetParaEmisora(emisora.uuid), + ); if (revision != _revisionReproduccion) return; notifyListeners(); } catch (e) { @@ -728,126 +721,66 @@ class EstadoRadio extends ChangeNotifier { Future esFavorito(String uuid) => favoritos.esFavorito(uuid); // ── Ecualizador ─────────────────────────────────────────────────────────── + // Transition bridge (S4a): EQ state lives in EstadoEcualizador; these + // delegating members keep legacy call sites compiling. They do NOT notify + // EstadoRadio listeners (S4-R1-A). + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. bool tienePresetEcualizadorPorEmisora(String uuid) => - _presetsEmisoraMap.containsKey(uuid); + ecualizador.tienePresetPorEmisora(uuid); + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. PresetEcualizador? presetEcualizadorPorEmisora(String uuid) => - _presetsEmisoraMap[uuid]; - - PresetEcualizador _presetParaEmisora(String uuid) => - _presetsEmisoraMap[uuid] ?? _presetPrincipal; - - Future _aplicarPresetActivo(PresetEcualizador preset) async { - _presetActual = preset; - await audio.aplicarPreset(preset); - } + ecualizador.presetPorEmisora(uuid); + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. Future cambiarPresetPrincipalEcualizador( PresetEcualizador preset, { bool notificar = true, - }) async { - _presetPrincipal = preset; - await servicioEcualizador.guardarPrincipal(preset); - - final actual = emisoraActual; - final puedeAplicarAhora = - actual == null || !_presetsEmisoraMap.containsKey(actual.uuid); - if (puedeAplicarAhora) { - await _aplicarPresetActivo(preset); - } - - if (notificar) notifyListeners(); - } + }) => ecualizador.cambiarPresetPrincipal(preset, notificar: notificar); + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. Future guardarPresetEcualizadorPorEmisora( String uuid, PresetEcualizador preset, { bool notificar = true, - }) async { - _presetsEmisoraMap[uuid] = preset; - await servicioEcualizador.guardarPorEmisora(uuid, preset); - if (emisoraActual?.uuid == uuid) { - await _aplicarPresetActivo(preset); - } - if (notificar) notifyListeners(); - } + }) => ecualizador.guardarPresetPorEmisora(uuid, preset, notificar: notificar); + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. Future habilitarPresetEcualizadorPorEmisora( String uuid, { PresetEcualizador? base, bool notificar = true, - }) async { - final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal; - await guardarPresetEcualizadorPorEmisora( - uuid, - presetBase, - notificar: notificar, - ); - } + }) => ecualizador.habilitarPresetPorEmisora( + uuid, + base: base, + notificar: notificar, + ); + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. Future deshabilitarPresetEcualizadorPorEmisora( String uuid, { bool notificar = true, - }) async { - _presetsEmisoraMap.remove(uuid); - await servicioEcualizador.eliminarPorEmisora(uuid); - if (emisoraActual?.uuid == uuid) { - await _aplicarPresetActivo(_presetPrincipal); - } - if (notificar) notifyListeners(); - } + }) => ecualizador.deshabilitarPresetPorEmisora(uuid, notificar: notificar); + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. Future cambiarModoEcualizadorEmisoraActual({ required bool usarPropio, - }) async { - final actual = emisoraActual; - if (actual == null) return; - if (usarPropio) { - await habilitarPresetEcualizadorPorEmisora(actual.uuid); - } else { - await deshabilitarPresetEcualizadorPorEmisora(actual.uuid); - } - } + }) => ecualizador.cambiarModoEmisoraActual(usarPropio: usarPropio); - Future cambiarEcualizadorActivo(bool activo) async { - _ecualizadorActivo = activo; - await servicioEcualizador.guardarActivo(activo); - await audio.setEcualizadorActivo(activo); - if (activo) { - await audio.aplicarPreset(_presetActual); - } - notifyListeners(); - } + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. + Future cambiarEcualizadorActivo(bool activo) => + ecualizador.cambiarActivo(activo); + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. Future cambiarPresetEcualizador( PresetEcualizador preset, { bool guardarPorEmisora = true, - }) async { - final actual = emisoraActual; - final usarPresetPropio = - guardarPorEmisora && - actual != null && - _presetsEmisoraMap.containsKey(actual.uuid); + }) => ecualizador.cambiarPreset(preset, guardarPorEmisora: guardarPorEmisora); - if (usarPresetPropio) { - await guardarPresetEcualizadorPorEmisora(actual.uuid, preset); - return; - } - await cambiarPresetPrincipalEcualizador(preset); - } - - Future cambiarBandaEcualizador(int index, double db) async { - final bandas = List.from(_presetActual.bandas); - if (index < 0 || index >= bandas.length) return; - - bandas[index] = db; - final modificado = PresetEcualizador( - nombre: 'Personalizado', - bandas: bandas, - ); - await cambiarPresetEcualizador(modificado); - } + // TODO(S4b): remove getter — consumers migrate to EstadoEcualizador. + Future cambiarBandaEcualizador(int index, double db) => + ecualizador.cambiarBanda(index, db); // ── Emisoras personalizadas ─────────────────────────────────────────────── @@ -912,6 +845,7 @@ class EstadoRadio extends ChangeNotifier { 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(); @@ -925,29 +859,27 @@ class EstadoRadio extends ChangeNotifier { ? jsonDecode(alarmasRaw) as Map : null; - return { - 'version': 2, - 'exportedAt': DateTime.now().toIso8601String(), - // Favoritos + grupos (preserva asignaciones grupo_id en cada emisora) - 'gruposFavoritos': - grupos.where((g) => !g.esSinAsignar).map((g) => g.toMap()).toList(), - 'favoritos': favs.map((e) => e.toMap()).toList(), - // Emisoras personalizadas - 'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(), - // Ecualizador - 'presetPrincipalEcualizador': _presetPrincipal.toJson(), - 'presetsEcualizador': _presetsEmisoraMap.map( - (uuid, preset) => MapEntry(uuid, preset.toJson()), - ), - // Alarmas completas (alarmas + vacaciones + excepciones) - 'alarmas': alarmasData, - // Preferencias de usuario - 'emisoraPreferidaUuid': _emisoraPreferidaUuid, - 'ordenListas': _ordenListas.name, - 'timerSuenoPresetsSegundos': _timerSuenoPresetsSegundos, - }; + 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 { @@ -985,40 +917,27 @@ class EstadoRadio extends ChangeNotifier { // ── Ecualizador ─────────────────────────────────────────────────────── final principalRaw = data['presetPrincipalEcualizador']; - if (principalRaw is Map) { - _presetPrincipal = PresetEcualizador.desdeJson( - Map.from(principalRaw), - ); - } else { - _presetPrincipal = PresetEcualizador.flat; - } + final presetPrincipal = + principalRaw is Map + ? PresetEcualizador.desdeJson( + Map.from(principalRaw), + ) + : PresetEcualizador.flat; final presetsRaw = data['presetsEcualizador'] as Map? ?? {}; - _presetsEmisoraMap - ..clear() - ..addAll( - presetsRaw.map( - (uuid, presetJson) => MapEntry( - uuid as String, - PresetEcualizador.desdeJson( - Map.from(presetJson as Map), - ), - ), + final presetsPorEmisora = presetsRaw.map( + (uuid, presetJson) => MapEntry( + uuid as String, + PresetEcualizador.desdeJson( + Map.from(presetJson as Map), ), - ); - - await servicioEcualizador.guardarConfiguracion( - ConfiguracionEcualizador( - principal: _presetPrincipal, - porEmisora: _presetsEmisoraMap, - activo: _ecualizadorActivo, ), ); - final actual = emisoraActual; - final presetActivo = - actual == null ? _presetPrincipal : _presetParaEmisora(actual.uuid); - await _aplicarPresetActivo(presetActivo); + await ecualizador.importarConfiguracion( + principal: presetPrincipal, + porEmisora: presetsPorEmisora, + ); // ── Alarmas (v2) ────────────────────────────────────────────────────── if (version >= 2) { @@ -1122,6 +1041,7 @@ class EstadoRadio extends ChangeNotifier { _suscripcionEstadoAudio?.cancel(); _suscripcionGrabacion?.cancel(); _errorController.close(); + ecualizador.dispose(); audio.dispose(); unawaited(grabacion.dispose()); timer.dispose(); diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 4672c4f..b768315 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -9,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart' show Share, XFile; import 'package:uuid/uuid.dart'; +import '../estado/estado_ecualizador.dart'; import '../estado/estado_idioma.dart'; import '../estado/estado_radio.dart'; import '../l10n/display_names.dart'; @@ -336,10 +336,7 @@ class _SeccionTimerSueno extends StatelessWidget { for (final segundos in presets) InputChip( label: Text( - _formatearDuracionTimer( - l10n, - Duration(seconds: segundos), - ), + _formatearDuracionTimer(l10n, Duration(seconds: segundos)), ), onDeleted: presets.length <= 1 @@ -574,14 +571,16 @@ class _SeccionEcualizador extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer( - builder: (ctx, estado, _) { - final disponible = estado.ecualizadorDisponible; + // EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is + // only consulted for the current station + favorite flag. + return Consumer2( + builder: (ctx, estado, eq, _) { + final disponible = eq.disponible; final l10n = AppLocalizations.of(ctx); final emisoraActual = estado.emisoraActual; final mostrarModoPorEmisora = emisoraActual != null && estado.emisoraActualEsFavorita; - final usandoEqPropio = estado.emisoraActualTienePresetPropio; + final usandoEqPropio = eq.emisoraActualTienePresetPropio; return PluriGlassSurface( child: Column( @@ -598,9 +597,7 @@ class _SeccionEcualizador extends StatelessWidget { const Spacer(), Chip( label: Text( - estado.ecualizadorActivo - ? l10n.equalizerActive - : l10n.equalizerDisabled, + eq.activo ? l10n.equalizerActive : l10n.equalizerDisabled, ), visualDensity: VisualDensity.compact, ), @@ -615,8 +612,8 @@ class _SeccionEcualizador extends StatelessWidget { ? l10n.equalizerRealtimeSubtitle : l10n.equalizerPendingSubtitle, ), - value: estado.ecualizadorActivo, - onChanged: estado.cambiarEcualizadorActivo, + value: eq.activo, + onChanged: eq.cambiarActivo, ), if (mostrarModoPorEmisora) ...[ const SizedBox(height: 8), @@ -631,20 +628,18 @@ class _SeccionEcualizador extends StatelessWidget { value: usandoEqPropio, onChanged: (usarPropio) => - estado.cambiarModoEcualizadorEmisoraActual( - usarPropio: usarPropio, - ), + eq.cambiarModoEmisoraActual(usarPropio: usarPropio), ), ], const SizedBox(height: 8), PresetsEcualizadorWidget( - presetActual: estado.presetEcualizador, - onSeleccionar: (p) => estado.cambiarPresetEcualizador(p), + presetActual: eq.presetActual, + onSeleccionar: (p) => eq.cambiarPreset(p), ), const SizedBox(height: 12), EcualizadorWidget( - preset: estado.presetEcualizador, - onCambio: (p) => estado.cambiarPresetEcualizador(p), + preset: eq.presetActual, + onCambio: (p) => eq.cambiarPreset(p), ), ], ), @@ -1176,8 +1171,8 @@ class _SeccionBackup extends StatelessWidget { final l10n = AppLocalizations.of(context); try { final estado = context.read(); - final config = await estado.exportarConfig(); - final json = const JsonEncoder.withIndent(' ').convert(config); + // JSON serialization is owned by ServicioExportImport (S4-R4). + final json = await estado.exportarConfigJson(); final dir = await getTemporaryDirectory(); final file = File('${dir.path}/pluriwave-backup.json'); @@ -1207,8 +1202,14 @@ class _SeccionBackup extends StatelessWidget { if (result == null || result.files.single.path == null) return; final file = File(result.files.single.path!); - final json = - jsonDecode(await file.readAsString()) as Map; + if (!context.mounted) return; + // Parsing is owned by ServicioExportImport (S4-R4): null = malformed. + final json = context.read().parsearConfigJson( + await file.readAsString(), + ); + if (json == null) { + throw const FormatException('invalid backup file'); + } if (context.mounted) { final confirmar = await showDialog( @@ -1365,10 +1366,7 @@ class _SeccionInfo extends StatelessWidget { } } -String _formatearDuracionTimer( - AppLocalizations l10n, - Duration duracion, -) { +String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) { final horas = duracion.inHours; final minutos = duracion.inMinutes.remainder(60); final segundos = duracion.inSeconds.remainder(60); diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 6783b9a..93ad2a6 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -4,6 +4,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; +import '../estado/estado_ecualizador.dart'; import '../estado/estado_radio.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; @@ -76,6 +77,9 @@ class _PantallaReproductorState extends State final theme = Theme.of(context); final tokens = context.pluriTokens; final estado = context.watch(); + // EQ toggle state lives in EstadoEcualizador (S4-R1); EstadoRadio no + // longer notifies on EQ changes, so this screen watches both. + final eq = context.watch(); final l10n = AppLocalizations.of(context); final emisoraActiva = estado.emisoraActual ?? widget.emisora; final esFavorito = estado.listaFavoritos.any( @@ -94,18 +98,11 @@ class _PantallaReproductorState extends State actions: [ IconButton( icon: Icon( - estado.ecualizadorActivo - ? Icons.equalizer_rounded - : Icons.equalizer_outlined, - color: estado.ecualizadorActivo ? tokens.warmCoral : null, + eq.activo ? Icons.equalizer_rounded : Icons.equalizer_outlined, + color: eq.activo ? tokens.warmCoral : null, ), - tooltip: - estado.ecualizadorActivo - ? l10n.equalizerDisable - : l10n.equalizerEnable, - onPressed: - () => - estado.cambiarEcualizadorActivo(!estado.ecualizadorActivo), + tooltip: eq.activo ? l10n.equalizerDisable : l10n.equalizerEnable, + onPressed: () => eq.cambiarActivo(!eq.activo), ), IconButton( icon: Icon( diff --git a/lib/servicios/servicio_export_import.dart b/lib/servicios/servicio_export_import.dart new file mode 100644 index 0000000..44aece5 --- /dev/null +++ b/lib/servicios/servicio_export_import.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import '../modelos/emisora.dart'; +import '../modelos/grupo_favoritos.dart'; +import '../modelos/preset_ecualizador.dart'; + +/// Owns the backup (export/import) JSON serialization (S4-R4). +/// +/// The v2 envelope produced here is byte-compatible with the legacy format +/// previously assembled inline by `EstadoRadio.exportarConfig` and +/// pretty-printed by `pantalla_ajustes.dart`, so existing exports keep +/// round-tripping. State APPLICATION (writing favorites, EQ, alarms back +/// into the app) stays in `EstadoRadio.importarConfig` — this service only +/// owns serialization, parsing and the envelope shape. +class ServicioExportImport { + const ServicioExportImport(); + + /// Current backup schema version (v2 — full portability). + static const int versionActual = 2; + + /// Builds the v2 export envelope. Key set and semantics must stay exactly + /// as the legacy inline export so old backups remain importable: + /// the `alarmas` block is the RAW JSON map persisted by ServicioAlarmas + /// and passes through untouched (no re-parsing here). + Map construirExportacion({ + required List gruposFavoritos, + required List favoritos, + required List emisorasCustom, + required PresetEcualizador presetPrincipal, + required Map presetsPorEmisora, + required Map? alarmas, + required String? emisoraPreferidaUuid, + required String ordenListas, + required List timerSuenoPresetsSegundos, + DateTime? exportadoEn, + }) { + return { + 'version': versionActual, + 'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(), + // Favorites + groups (preserves grupo_id assignments per station). + // The protected "sin asignar" group is implicit and never exported. + 'gruposFavoritos': + gruposFavoritos + .where((g) => !g.esSinAsignar) + .map((g) => g.toMap()) + .toList(), + 'favoritos': favoritos.map((e) => e.toMap()).toList(), + // Custom stations. + 'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(), + // Equalizer. + 'presetPrincipalEcualizador': presetPrincipal.toJson(), + 'presetsEcualizador': presetsPorEmisora.map( + (uuid, preset) => MapEntry(uuid, preset.toJson()), + ), + // Full alarm block (alarms + vacations + exceptions) — raw passthrough. + 'alarmas': alarmas, + // User preferences. + 'emisoraPreferidaUuid': emisoraPreferidaUuid, + 'ordenListas': ordenListas, + 'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos, + }; + } + + /// Serializes an export envelope to pretty-printed JSON (same formatting + /// the legacy export shared as a file). + String exportar(Map config) => + const JsonEncoder.withIndent(' ').convert(config); + + /// Parses a backup JSON string. Returns `null` on malformed input or when + /// the document is not a JSON object — graceful, never throws (S4-R4). + Map? importar(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return Map.from(decoded); + } on FormatException { + return null; + } + } +} diff --git a/openspec/changes/app-quality-and-native-alarms/apply-progress.md b/openspec/changes/app-quality-and-native-alarms/apply-progress.md index dd46d74..27b9e37 100644 --- a/openspec/changes/app-quality-and-native-alarms/apply-progress.md +++ b/openspec/changes/app-quality-and-native-alarms/apply-progress.md @@ -3,7 +3,7 @@ **Mode**: Strict TDD (test runner: `flutter test`) **Artifact store**: openspec (Engram unavailable this session) **Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence) -**Last updated**: 2026-06-11 (Batch 4) +**Last updated**: 2026-06-11 (Batch 5) ## Batch log @@ -13,6 +13,7 @@ | 2 | S2a + S2b — Snooze correctness end-to-end + Alarm UX parity | COMPLETE (Dart verified; Kotlin on-device verification deferred to user) | 2026-06-11 | | 3 | S3a + S3b — Test seams (statics/prefs/cache/mutex/dirty-guard/bounded set) + audio_session | COMPLETE (Dart-only batch; call-pause on-device verification deferred to user) | 2026-06-11 | | 4 | S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) | COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) | 2026-06-11 | +| 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 2026-06-11 | ## Task status (cumulative) @@ -130,9 +131,24 @@ | T-S7-12 | [x] | `flutter analyze` — No issues found | | T-S7-13 | [x] | `dart format` on 9 touched files (2 reflowed); re-ran suite + analyze after format | +### Slice S4a — ServicioExportImport + EstadoEcualizador — 10/10 complete + +| Task | Status | Notes | +|------|--------|-------| +| T-S4a-01 | [x] | RED: `servicio_export_import_test.dart` — v2 round-trip deep-equal (favorites/groups/EQ/alarms/vacations, alarmas raw passthrough, "sin asignar" excluded) + malformed JSON → null (invalid/empty/non-object) | +| T-S4a-02 | [x] | RED: `estado_ecualizador_test.dart` — `cambiarPreset` notifies EQ listeners; EQ change does NOT notify EstadoRadio listeners (radio = 0) | +| T-S4a-03 | [x] | GREEN: `lib/servicios/servicio_export_import.dart` — `construirExportacion` (v2 envelope, exact legacy key set), `exportar` (pretty JSON), `importar` (graceful `Map?`). pantalla_ajustes lost `dart:convert`; EstadoRadio gained `exportarConfigJson`/`parsearConfigJson` | +| T-S4a-04 | [x] | GREEN: `lib/estado/estado_ecualizador.dart` — full EQ state + persistence + audio application; `emisoraActualUuid` callback seam; `ListenableProvider` registration in app.dart (owned/disposed by EstadoRadio) | +| T-S4a-05 | [x] | GREEN: EstadoRadio keeps 15 delegating members, all tagged `// TODO(S4b): remove getter`; EQ fields and private helpers removed | +| T-S4a-06 | [x] | GREEN: `_SeccionEcualizador` → `Consumer2`; `ecualizador_widget.dart` already presentational (no change); pantalla_reproductor EQ toggle also rewired (deviation 3) | +| T-S4a-07 | [x] | Targeted run 4/4 green (RED first: `+0 -2` load failures) | +| T-S4a-08 | [x] | Full suite 103/103 (99 baseline + 4 new) | +| T-S4a-09 | [x] | `flutter analyze` — No issues found | +| T-S4a-10 | [x] | `dart format` on 8 touched files (4 reflowed); analyze + suite re-run after format | + ### Remaining slices (not started) -S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending. +S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending. ## Snooze defect fixes (design audit D1–D5 / S1–S5) @@ -182,6 +198,15 @@ RED run evidence (Batch 3): `00:06 +1 -6` before implementation (the single pass RED run evidence (Batch 4): `00:00 +0 -2` (both files fail to load). GREEN: targeted `00:01 +10: All tests passed!`; full suite `00:08 +99: All tests passed!` (89 baseline + 10 new). +### Batch 5 TDD Cycle Evidence (S4a) + +| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR | +|------|-----------------------------------|-------------------------------|----------| +| T-S4a-01/T-S4a-03 | Load failure: `servicio_export_import.dart` missing (`+0 -2` run) | Service created; round-trip + malformed tests pass | Envelope comments tied to legacy format compatibility | +| T-S4a-02/T-S4a-04/05 | Same RED run: `estado_ecualizador.dart` missing, `estado.ecualizador` undefined | EQ notifier + EstadoRadio delegation; both tests pass | `dart format` reflow; delegation kept expression-bodied | + +RED run evidence (Batch 5): `00:00 +0 -2` (both files fail to load — captured before any lib code). GREEN: targeted `00:00 +4: All tests passed!`; full suite `00:12 +103: All tests passed!` (99 baseline + 4 new); analyze + suite re-run after format. + ## Files changed (Batch 2) | File | Action | ~Lines | @@ -252,6 +277,30 @@ Total Batch 3 diff: ~362 insertions / ~122 deletions in lib + helpers, plus ~458 Total Batch 4 diff: ~235 insertions / ~13 deletions in lib (incl. ARB/gen), plus ~310 lines of new tests. Within the ~285-line slice estimate. No Kotlin/native files touched. +## Files changed (Batch 5) + +| File | Action | ~Lines | +|------|--------|--------| +| `lib/servicios/servicio_export_import.dart` | Created | +80 (v2 envelope builder, pretty export, graceful parse) | +| `lib/estado/estado_ecualizador.dart` | Created | +205 (EQ ChangeNotifier: presets, per-station map, activo, persistence, audio application, import path) | +| `lib/estado/estado_radio.dart` | Modified | +122/-208 (EQ state/methods extracted; 15 `// TODO(S4b)` delegating members; exportarConfig delegates envelope; importarConfig delegates EQ; exportarConfigJson/parsearConfigJson; ecualizador owned + disposed) | +| `lib/pantallas/pantalla_ajustes.dart` | Modified | +33/-23 (backup section delegates JSON to service, `dart:convert` removed; `_SeccionEcualizador` → Consumer2 with EstadoEcualizador) | +| `lib/pantallas/pantalla_reproductor.dart` | Modified | +8/-11 (EQ toggle watches EstadoEcualizador) | +| `lib/app.dart` | Modified | +7 (ListenableProvider exposing EstadoRadio's instance) | +| `test/servicios/servicio_export_import_test.dart` | Created | +85 (2 tests) | +| `test/estado/estado_ecualizador_test.dart` | Created | +52 (2 tests) | + +Total Batch 5 diff: ~455 insertions / ~242 deletions in lib, plus ~137 lines of new tests. Slightly over the ~350-line slice estimate because the EQ method bodies moved (not duplicated) into the new notifier — net lib growth is ~+213. No Kotlin/native files touched. + +## Deviations from design (Batch 5) + +1. **`importar()` returns `Map?`, not a `ConfiguracionCompleta` model** (task text suggested one). EstadoRadio's `importarConfig(Map)` is the existing application API with v1/v2 branching and a localized version-guard error; introducing a typed model would force re-validating/re-mapping every section twice in a slice that must stay under budget. The service's contract (graceful null on malformed, version inside the map) covers S4-R4; a typed model can land with S4b/S6 if wanted. +2. **`ListenableProvider` instead of `ProxyProvider`** for EstadoEcualizador registration. The notifier needs `ServicioAudio` from EstadoRadio at CONSTRUCTION; EstadoRadio therefore constructs and disposes it (transition ownership), and the provider only exposes the instance (`create: ctx.read().ecualizador`, no dispose callback — avoids double-dispose). In S4b, when EstadoRadio sheds the remaining EQ surface, ownership can be inverted if desired. +3. **`pantalla_reproductor.dart` EQ toggle rewired in S4a** (task listed only ecualizador_widget + pantalla_ajustes). EstadoRadio no longer notifies on EQ changes (required by S4-R1-A test B), so any screen still reading EQ through EstadoRadio's compat getters under `watch` would go STALE on toggle. The reproductor button was the only such site; 8-line fix beats shipping a known visual bug until S4b. +4. **`ecualizador_widget.dart` needed NO change**: both widgets in it are presentational (preset/onCambio props, no provider reads), so T-S4a-06's intent (scoped consumption) is satisfied at the call sites in pantalla_ajustes. +5. **Compat getters do NOT relay EQ notifications to EstadoRadio listeners** — intentional and spec-mandated (S4-R1-A scenario). EQ-displaying UI was rewired in this same slice precisely because of this; S4b removes the getters entirely. +6. **`emisoraActualUuid` callback seam** on EstadoEcualizador (not in task text): per-station preset decisions need the currently playing station; a `String? Function()` injected by EstadoRadio keeps the notifier free of station-list coupling and trivially testable. + ## Deviations from design (Batch 3) 1. **No deprecated static shim for `ServicioAlarmasAndroid.configurarLocalizaciones`** (task text asked for one). Dart forbids a static and an instance member with the same name in one class, and the ONLY external call site (`estado_radio.dart:74`) was rewired in this same slice — a renamed shim would be unreachable dead code. Instead `configurarLocalizaciones` joined the `PuertoAlarmasAndroid` interface (fakes no-op it). @@ -353,9 +402,23 @@ From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 it 6. **Recording during a drop (S7-R5):** record while forcing a drop → recording fails/stops cleanly per existing behavior; a subsequent recording start works. 7. **Sleep timer during a drop (S7-R6):** sleep timer expiring during "Reconectando..." stops audio for good. +## Verification summary (Batch 5) + +- `flutter test`: 103/103 passing (99 baseline + 4 new across 2 files); re-run after `dart format` +- `flutter analyze`: No issues found (identical to baseline); re-run after format +- `dart format`: applied to all 8 touched Dart files (4 reflowed) +- `flutter build`: NOT run (forbidden) +- No Kotlin/native, .arb or gen/ files touched in this batch + +### Manual verification items added by Batch 5 (user) + +1. **Backup round-trip on device (S4-R4):** export a backup from Ajustes, wipe/reinstall (or import on a second device), import the file → favorites, groups, custom stations, EQ presets, alarms (incl. vacations) and preferences all restored. Old (pre-S4a) backup files must import identically — the v2 envelope is byte-compatible. +2. **EQ controls still live-update (S4-R1):** toggle EQ from the player screen and from Ajustes; chip/switch/preset selector reflect changes immediately (these now rebuild from EstadoEcualizador, not EstadoRadio). +3. **Per-station preset on playback switch:** play a station with its own preset, switch to one without → main preset re-applies (path now goes through EstadoEcualizador). + ## Workload / boundary - Mode: auto-chain local slices (no PRs) -- Current work units: S1, S2a, S2b, S3a, S3b (committed f3e9487, 079e19f), S7 (complete, in working tree) -- Boundary (Batch 4): starts from the clean post-079e19f tree; ends with S7 fully checked off, suite green (99/99). Rollback = revert the Batch-4 files listed above (Dart-only; no native edits). -- Next batch: S4a (ServicioExportImport + EstadoEcualizador extraction). No dependency on S7; on-device items above can be verified in parallel. +- Current work units: S1, S2a, S2b, S3a, S3b, S7 (committed, latest 0380bbb), S4a (complete, in working tree) +- Boundary (Batch 5): starts from the clean post-0380bbb tree; ends with S4a fully checked off, suite green (103/103). Rollback = revert the 6 lib files + delete the 2 new test files (Dart-only; no native edits). +- Next batch: S4b (EstadoGrabacion + EstadoBusqueda + context.select rewiring + REMOVE the 15 `// TODO(S4b)` compat members added here). S5 is also unblocked (depends only on S2b). diff --git a/openspec/changes/app-quality-and-native-alarms/tasks.md b/openspec/changes/app-quality-and-native-alarms/tasks.md index 9e56fd3..bd2e236 100644 --- a/openspec/changes/app-quality-and-native-alarms/tasks.md +++ b/openspec/changes/app-quality-and-native-alarms/tasks.md @@ -294,28 +294,28 @@ Chain strategy: N/A (local apply) ### S4a pre-work: write failing tests -- [ ] **T-S4a-01** [RED] Create `test/servicios/servicio_export_import_test.dart`: +- [x] **T-S4a-01** [RED] Create `test/servicios/servicio_export_import_test.dart`: - Test A: full round-trip (favorites, groups, EQ, alarms, vacations) — serialize then deserialize produces deep-equal config. (S4-R4-A, S6-R2 test #4) - Test B: malformed JSON input to `importar()` → graceful empty result, no throw. (S4-R4) - **~40 lines.** -- [ ] **T-S4a-02** [RED] Create `test/estado/estado_ecualizador_test.dart`: + **DONE — round-trip also locks alarmas raw passthrough + "sin asignar" group never exported; malformed cases: invalid JSON, empty string, non-object JSON.** +- [x] **T-S4a-02** [RED] Create `test/estado/estado_ecualizador_test.dart`: - Test A: `aplicarPreset` notifies `EstadoEcualizador` listeners. (S4-R1-A) - Test B: `EstadoRadio` listeners are NOT rebuilt on EQ preset change. (S4-R1-A, S4-R5) - **~30 lines.** + **DONE — Test A via `cambiarPreset` (the public preset-change API); Test B counts both notifiers on `estado.ecualizador.cambiarPreset` (radio = 0, eq ≥ 1).** ### S4a implementation -- [ ] **T-S4a-03** [GREEN] Create `lib/servicios/servicio_export_import.dart`: `ServicioExportImport` class with `exportar(config) → String` and `importar(json) → ConfiguracionCompleta?`. Move all `jsonEncode`/`jsonDecode` backup/restore logic from `lib/pantallas/pantalla_ajustes.dart` (~1391 lines). `PantallaAjustes` delegates to this service. **Reqs:** S4-R4. **~100 lines (service) + ~30 lines cleanup in pantalla_ajustes.** -- [ ] **T-S4a-04** [GREEN] Create `lib/estado/estado_ecualizador.dart`: `EstadoEcualizador extends ChangeNotifier` — owns preset, bands, enabled flag. Move EQ state fields and methods from `lib/estado/estado_radio.dart`. Add `ProxyProvider` registration in `MultiProvider` (wherever providers are registered, likely `lib/main.dart` or `lib/app.dart`). **Reqs:** S4-R1. **~90 lines.** -- [ ] **T-S4a-05** [GREEN] Edit `lib/estado/estado_radio.dart`: add backward-compatible getters delegating EQ state to `EstadoEcualizador` (transition bridge). These are removed in S4b. Add `// TODO(S4b): remove getter` comments. **~20 lines.** -- [ ] **T-S4a-06** [GREEN] Edit `lib/widgets/ecualizador_widget.dart` and `lib/pantallas/pantalla_ajustes.dart` EQ sections: consume `context.watch()` (scoped). Screens still compile via compat getters if missed. **Reqs:** S4-R5. **~20 lines.** +- [x] **T-S4a-03** [GREEN] Create `lib/servicios/servicio_export_import.dart`: `ServicioExportImport` class with `exportar(config) → String` and `importar(json) → ConfiguracionCompleta?`. Move all `jsonEncode`/`jsonDecode` backup/restore logic from `lib/pantallas/pantalla_ajustes.dart` (~1391 lines). `PantallaAjustes` delegates to this service. **Reqs:** S4-R4. **DONE — service owns the v2 envelope (`construirExportacion`), pretty-print (`exportar`) and graceful parse (`importar` → `Map?`, null on malformed). `dart:convert` removed from pantalla_ajustes; EstadoRadio exposes `exportarConfigJson`/`parsearConfigJson`. DEVIATION: `Map` instead of a `ConfiguracionCompleta` model (see apply-progress).** +- [x] **T-S4a-04** [GREEN] Create `lib/estado/estado_ecualizador.dart`: `EstadoEcualizador extends ChangeNotifier` — owns preset, bands, enabled flag. Move EQ state fields and methods from `lib/estado/estado_radio.dart`. Add `ProxyProvider` registration in `MultiProvider` (wherever providers are registered, likely `lib/main.dart` or `lib/app.dart`). **Reqs:** S4-R1. **DONE — owns principal/actual/per-station presets + activo, persistence via ServicioEcualizador, application via ServicioAudio; `emisoraActualUuid` callback decouples it from station lists. Registered via `ListenableProvider` (not ProxyProvider — see deviation) in `app.dart`; instance owned/disposed by EstadoRadio during the S4 transition.** +- [x] **T-S4a-05** [GREEN] Edit `lib/estado/estado_radio.dart`: add backward-compatible getters delegating EQ state to `EstadoEcualizador` (transition bridge). These are removed in S4b. Add `// TODO(S4b): remove getter` comments. **DONE — 15 delegating getters/methods, every one tagged `// TODO(S4b): remove getter`. EQ fields, `_cargarEcualizadorPersistido`, `_aplicarPresetActivo`, `_presetParaEmisora` removed from EstadoRadio.** +- [x] **T-S4a-06** [GREEN] Edit `lib/widgets/ecualizador_widget.dart` and `lib/pantallas/pantalla_ajustes.dart` EQ sections: consume `context.watch()` (scoped). Screens still compile via compat getters if missed. **Reqs:** S4-R5. **DONE — `_SeccionEcualizador` now `Consumer2` (radio only for station/favorite info); `ecualizador_widget.dart` is purely presentational (props + callbacks), no change needed. ALSO rewired `pantalla_reproductor.dart` EQ toggle (required for correctness — see deviation).** ### S4a verification -- [ ] **T-S4a-07** Run `flutter test test/servicios/servicio_export_import_test.dart test/estado/estado_ecualizador_test.dart`. -- [ ] **T-S4a-08** Run `flutter test` (full suite) — no regressions. -- [ ] **T-S4a-09** Run `flutter analyze` — zero errors. -- [ ] **T-S4a-10** Run `dart format lib/servicios/servicio_export_import.dart lib/estado/estado_ecualizador.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_ajustes.dart`. +- [x] **T-S4a-07** Run `flutter test test/servicios/servicio_export_import_test.dart test/estado/estado_ecualizador_test.dart` — 4/4 green (RED captured first: `+0 -2` load failures). +- [x] **T-S4a-08** Run `flutter test` (full suite) — 103/103 passing (99 baseline + 4 new), no regressions. +- [x] **T-S4a-09** Run `flutter analyze` — `No issues found!`. +- [x] **T-S4a-10** Run `dart format` on all 8 touched Dart files (4 reflowed); analyze + full suite re-run after format. ### S4a Definition of Done - `flutter test` green. diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart new file mode 100644 index 0000000..9ac8d01 --- /dev/null +++ b/test/estado/estado_ecualizador_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; + +import '../helpers/fakes.dart'; + +void main() { + group('EstadoEcualizador (S4-R1)', () { + test( + 'cambiarPreset notifies EstadoEcualizador listeners (S4-R1-A)', + () async { + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador( + audio: audio, + servicio: FakeServicioEcualizador(), + ); + var avisos = 0; + eq.addListener(() => avisos++); + + await eq.cambiarPreset(PresetEcualizador.jazz); + + expect(avisos, greaterThanOrEqualTo(1)); + expect(eq.presetActual, PresetEcualizador.jazz); + expect(audio.presetsAplicados, contains(PresetEcualizador.jazz)); + eq.dispose(); + }, + ); + + test('EQ preset change does NOT rebuild EstadoRadio listeners ' + '(S4-R1-A, S4-R5)', () async { + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + iniciarAutomaticamente: false, + ); + var avisosRadio = 0; + var avisosEq = 0; + estado.addListener(() => avisosRadio++); + estado.ecualizador.addListener(() => avisosEq++); + + await estado.ecualizador.cambiarPreset(PresetEcualizador.jazz); + + expect(avisosEq, greaterThanOrEqualTo(1)); + expect(avisosRadio, 0); + estado.dispose(); + }); + }); +} diff --git a/test/servicios/servicio_export_import_test.dart b/test/servicios/servicio_export_import_test.dart new file mode 100644 index 0000000..5fef3a3 --- /dev/null +++ b/test/servicios/servicio_export_import_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/grupo_favoritos.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/servicios/servicio_export_import.dart'; + +void main() { + const servicio = ServicioExportImport(); + + group('ServicioExportImport (S4-R4)', () { + test('v2 round-trip: exportar then importar yields a deep-equal config ' + '(S4-R4-A, S6-R2 test #4)', () { + final config = servicio.construirExportacion( + gruposFavoritos: const [ + GrupoFavoritos(id: 'g1', nombre: 'Rock', orden: 1), + GrupoFavoritos( + id: GrupoFavoritos.sinAsignarId, + nombre: 'Sin asignar', + orden: 0, + protegido: true, + ), + ], + favoritos: const [ + Emisora( + uuid: 'fav-1', + nombre: 'Radio Uno', + url: 'https://uno.example/stream', + grupoFavoritosId: 'g1', + bitrate: 192, + ), + ], + emisorasCustom: const [ + Emisora( + uuid: 'custom-1', + nombre: 'Mi Radio', + url: 'https://mia.example/stream', + ), + ], + presetPrincipal: PresetEcualizador.jazz, + presetsPorEmisora: {'fav-1': PresetEcualizador.rock}, + // Raw alarm block exactly as ServicioAlarmas persists it (alarms + + // vacations + exceptions) — must pass through untouched. + alarmas: const { + 'alarmas': [ + { + 'id': 'a1', + 'hora': 7, + 'minuto': 30, + 'activa': true, + 'snoozeMinutos': 5, + }, + ], + 'vacaciones': [ + {'desde': '2026-07-01', 'hasta': '2026-07-15'}, + ], + }, + emisoraPreferidaUuid: 'fav-1', + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300, 600, 1800], + ); + + final json = servicio.exportar(config); + final importado = servicio.importar(json); + + expect(importado, isNotNull); + expect(importado, equals(config)); + expect(importado!['version'], 2); + // Alarm raw-JSON passthrough survives the round-trip intact. + expect(importado['alarmas'], equals(config['alarmas'])); + // The protected "sin asignar" group is never exported. + final grupos = importado['gruposFavoritos'] as List; + expect(grupos, hasLength(1)); + expect((grupos.single as Map)['id'], 'g1'); + }); + + test('malformed JSON returns null without throwing (S4-R4)', () { + expect(servicio.importar('{not valid json'), isNull); + expect(servicio.importar(''), isNull); + // Valid JSON but not an object — also rejected gracefully. + expect(servicio.importar('[1, 2, 3]'), isNull); + }); + }); +}