diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index a1ce397..aa2323d 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -695,12 +695,21 @@ class EstadoEcualizador extends ChangeNotifier { /// Replaces the whole EQ configuration (backup import path): persists it, /// re-applies the preset effective for the current station and notifies. + /// + /// [activo] is the imported on/off toggle (S4-R4/eq-export-toggle). When + /// `null` — an old backup with no `ecualizadorActivo` field — the CURRENT + /// toggle is left untouched: an absent flag must never flip the user's live + /// setting to an arbitrary value. When non-null, applies it through + /// [cambiarActivo], the same path a manual toggle uses, so the import + /// persists it AND pushes it to the live audio engine instead of just + /// updating [_activo] in memory. Future importarConfiguracion({ required PresetEcualizador principal, required Map porEmisora, Map? presetsDispositivo, Map? presetsMatriz, bool? eqMultiDeviceEnabled, + bool? activo, }) async { _presetPrincipal = principal; _presetsEmisoraMap @@ -735,6 +744,11 @@ class EstadoEcualizador extends ChangeNotifier { final presetEfectivoActual = uuid == null ? _presetPrincipal : _resolverPresetActivo(); await aplicarPresetActivo(presetEfectivoActual); + + if (activo != null) { + await cambiarActivo(activo); + } + notifyListeners(); } diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 10e9d8f..2248e2e 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -807,9 +807,10 @@ class EstadoRadio extends ChangeNotifier { static const _keyAlarmasConfig = 'alarmas_musicales_v1'; - /// Genera el JSON de toda la configuración (v3 — portabilidad completa - /// con presets por dispositivo y matriz multi-device). - /// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4). + /// Genera el JSON de toda la configuración (v4 — portabilidad completa + /// con presets por dispositivo, matriz multi-device y el toggle + /// on/off del ecualizador). + /// La forma del sobre vive en [ServicioExportImport] (S4-R4). Future> exportarConfig() async { final favs = await favoritos.obtenerTodos(); final grupos = await favoritos.obtenerGrupos(); @@ -837,6 +838,8 @@ class EstadoRadio extends ChangeNotifier { presetsPorDispositivo: ecualizador.presetsDispositivo, presetsMatriz: ecualizador.presetsMatriz, eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled, + // v4 extension — equalizer global on/off toggle. + ecualizadorActivo: ecualizador.activo, ); } @@ -850,10 +853,11 @@ class EstadoRadio extends ChangeNotifier { /// Importa configuración desde un JSON exportado previamente. /// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa), - /// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device). + /// v3 (+ presets por dispositivo, presets matriz, toggle multi-device) + /// y v4 (+ toggle on/off del ecualizador). Future importarConfig(Map data) async { final version = data['version'] as int? ?? 1; - if (version > 3) throw Exception(_textos.unsupportedConfigVersion); + if (version > 4) throw Exception(_textos.unsupportedConfigVersion); final prefs = await _resolverPrefs(); @@ -932,12 +936,20 @@ class EstadoRadio extends ChangeNotifier { 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) ────────────────────────────────────────────────────── diff --git a/lib/servicios/servicio_export_import.dart b/lib/servicios/servicio_export_import.dart index 5dbca6c..253acdb 100644 --- a/lib/servicios/servicio_export_import.dart +++ b/lib/servicios/servicio_export_import.dart @@ -7,26 +7,33 @@ import '../modelos/preset_ecualizador.dart'; /// Owns the backup (export/import) JSON serialization (S4-R4). /// /// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and -/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the -/// export stays at v2 for backward compat with the old app. State APPLICATION -/// (writing favorites, EQ, alarms back into the app) stays in +/// `eqMultiDeviceEnabled`. v4 extends v3 with `ecualizadorActivo` (the +/// equalizer's global ON/OFF toggle). When the version-N extension +/// parameters are all omitted the export stays at the lower version for +/// backward compat with older app builds. 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 (v3 — multi-device EQ). - static const int versionActual = 3; + /// Current backup schema version (v4 — equalizer on/off toggle). + static const int versionActual = 4; + + /// v3 version constant (multi-device EQ) kept for clarity. + static const int versionV3 = 3; /// Legacy v2 version constant kept for clarity. static const int versionV2 = 2; /// Builds the export envelope. /// - /// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null), - /// [versionActual] (3) is written. When both are omitted the call behaves - /// identically to the original v2 format (version key stays 2) so old - /// backups keep round-tripping without version bumps. + /// When [presetsPorDispositivo] or [presetsMatriz] or + /// [eqMultiDeviceEnabled] are provided (non-null), at least [versionV3] (3) + /// is written. When [ecualizadorActivo] is ALSO provided (non-null), + /// [versionActual] (4) is written. Omitting all of them behaves identically + /// to the original v2 format (version key stays 2) so old backups keep + /// round-tripping without version bumps. /// /// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas /// and passes through untouched (no re-parsing here). @@ -45,14 +52,27 @@ class ServicioExportImport { Map? presetsPorDispositivo, Map? presetsMatriz, bool? eqMultiDeviceEnabled, + // v4 extension — the equalizer's global ON/OFF toggle. Omitting it + // produces a v3 (or v2)-compatible export. + bool? ecualizadorActivo, }) { final tieneExtensionesV3 = presetsPorDispositivo != null || presetsMatriz != null || eqMultiDeviceEnabled != null; + final tieneExtensionV4 = ecualizadorActivo != null; + + final int version; + if (tieneExtensionV4) { + version = versionActual; + } else if (tieneExtensionesV3) { + version = versionV3; + } else { + version = versionV2; + } final envelope = { - 'version': tieneExtensionesV3 ? versionActual : versionV2, + 'version': version, 'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(), // Favorites + groups (preserves grupo_id assignments per station). // The protected "sin asignar" group is implicit and never exported. @@ -88,6 +108,11 @@ class ServicioExportImport { envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false; } + // v4 extension: only written when explicitly provided. + if (tieneExtensionV4) { + envelope['ecualizadorActivo'] = ecualizadorActivo; + } + return envelope; } diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 5823c60..069634d 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1569,6 +1569,83 @@ void main() { }); }); + group( + 'EstadoEcualizador — importarConfiguracion(activo:) ' + '(equalizer on/off export/import gap)', + () { + test( + 'activo: false turns the equalizer off — persisted AND pushed to ' + 'the live audio engine (reuses cambiarActivo, not a bare field set)', + () async { + final servicio = FakeServicioEcualizador(activo: true); + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador(audio: audio, servicio: servicio); + await eq.cargarPersistido(); + audio.cambiosEcualizadorActivo.clear(); + + await eq.importarConfiguracion( + principal: PresetEcualizador.flat, + porEmisora: {}, + activo: false, + ); + + expect(eq.activo, isFalse); + expect(audio.cambiosEcualizadorActivo, contains(false)); + expect(servicio.config.activo, isFalse); + expect(servicio.guardarActivoLlamadas, 1); + eq.dispose(); + }, + ); + + test( + 'activo: true turns the equalizer on — persisted AND pushed to ' + 'the live audio engine', + () async { + final servicio = FakeServicioEcualizador(activo: false); + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador(audio: audio, servicio: servicio); + await eq.cargarPersistido(); + audio.cambiosEcualizadorActivo.clear(); + + await eq.importarConfiguracion( + principal: PresetEcualizador.flat, + porEmisora: {}, + activo: true, + ); + + expect(eq.activo, isTrue); + expect(audio.cambiosEcualizadorActivo, contains(true)); + expect(servicio.config.activo, isTrue); + expect(servicio.guardarActivoLlamadas, 1); + eq.dispose(); + }, + ); + + test( + 'activo: null (old backup, no field) leaves the current toggle ' + 'untouched and does not persist anything for it', + () async { + final servicio = FakeServicioEcualizador(activo: false); + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador(audio: audio, servicio: servicio); + await eq.cargarPersistido(); + audio.cambiosEcualizadorActivo.clear(); + + await eq.importarConfiguracion( + principal: PresetEcualizador.flat, + porEmisora: {}, + // activo omitted — simulates a pre-v4 backup. + ); + + expect(eq.activo, isFalse); + expect(audio.cambiosEcualizadorActivo, isEmpty); + expect(servicio.guardarActivoLlamadas, 0); + eq.dispose(); + }, + ); + }, + ); + group('EstadoEcualizador — bonded Bluetooth names', () { const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; diff --git a/test/estado/estado_radio_export_import_eq_activo_test.dart b/test/estado/estado_radio_export_import_eq_activo_test.dart new file mode 100644 index 0000000..2a87572 --- /dev/null +++ b/test/estado/estado_radio_export_import_eq_activo_test.dart @@ -0,0 +1,171 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Closes the last remaining export/import gap: `EstadoEcualizador._activo` +/// (the equalizer's global ON/OFF toggle) was not part of the backup +/// envelope at all, so restoring a backup on another device silently kept +/// whatever that device's toggle happened to be. These tests exercise the +/// flag end to end through `EstadoRadio.exportarConfig`/`importarConfig`, +/// the real call sites `pantalla_ajustes_backup.dart` uses. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + // A PRIVATE per-test file, never the shared `test/fixtures/` one — see + // `estado_alarmas_import_test.dart` for why: `importarConfig` + // unconditionally writes to whatever `resolverArchivoCustom` resolves to. + tempDir = await Directory.systemTemp.createTemp( + 'pluriwave_eq_activo_export_test', + ); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + Future crearRadio({bool ecualizadorActivo = true}) async { + final prefs = await SharedPreferences.getInstance(); + final archivoCustom = File('${tempDir.path}/emisoras_custom.json'); + if (!archivoCustom.existsSync()) { + await archivoCustom.writeAsString('[]'); + } + final radio = EstadoRadio( + esPremium: () => true, + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(activo: ecualizadorActivo), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: () async => archivoCustom, + prefs: prefs, + iniciarAutomaticamente: false, + ); + await radio.ecualizador.cargarPersistido(); + return radio; + } + + Map backupBase({ + required int version, + bool? ecualizadorActivo, + }) { + final data = { + 'version': version, + 'gruposFavoritos': [], + 'favoritos': [], + 'emisorasCustom': [], + 'presetsEcualizador': {}, + 'alarmas': null, + 'emisoraPreferidaUuid': null, + 'ordenListas': 'nombre', + 'timerSuenoPresetsSegundos': [300, 600], + }; + if (ecualizadorActivo != null) { + data['ecualizadorActivo'] = ecualizadorActivo; + } + return data; + } + + group('EstadoRadio export/import — equalizer on/off toggle (v4)', () { + test('exportarConfig includes the flag when the equalizer is ON', () async { + final radio = await crearRadio(ecualizadorActivo: true); + addTearDown(radio.dispose); + + final exportado = await radio.exportarConfig(); + + expect(exportado['ecualizadorActivo'], isTrue); + expect(exportado['version'], 4); + }); + + test( + 'exportarConfig includes the flag when the equalizer is OFF', + () async { + final radio = await crearRadio(ecualizadorActivo: false); + addTearDown(radio.dispose); + + final exportado = await radio.exportarConfig(); + + expect(exportado['ecualizadorActivo'], isFalse); + expect(exportado['version'], 4); + }, + ); + + test('importarConfig(activo: false) turns the equalizer off — persisted ' + 'and reflected in EstadoEcualizador.activo', () async { + final radio = await crearRadio(ecualizadorActivo: true); + addTearDown(radio.dispose); + expect(radio.ecualizador.activo, isTrue); + + await radio.importarConfig( + backupBase(version: 4, ecualizadorActivo: false), + ); + + expect(radio.ecualizador.activo, isFalse); + }); + + test('importarConfig(activo: true) turns the equalizer on — persisted ' + 'and reflected in EstadoEcualizador.activo', () async { + final radio = await crearRadio(ecualizadorActivo: false); + addTearDown(radio.dispose); + expect(radio.ecualizador.activo, isFalse); + + await radio.importarConfig( + backupBase(version: 4, ecualizadorActivo: true), + ); + + expect(radio.ecualizador.activo, isTrue); + }); + + test('importing an OLD backup (no ecualizadorActivo field) does not throw ' + 'and leaves the current toggle untouched', () async { + final radio = await crearRadio(ecualizadorActivo: false); + addTearDown(radio.dispose); + expect(radio.ecualizador.activo, isFalse); + + await radio.importarConfig(backupBase(version: 2)); + + expect(radio.ecualizador.activo, isFalse); + }); + + test( + 'full round-trip: export -> import restores the equalizer toggle', + () async { + final origen = await crearRadio(ecualizadorActivo: false); + addTearDown(origen.dispose); + final exportado = await origen.exportarConfig(); + + final destino = await crearRadio(ecualizadorActivo: true); + addTearDown(destino.dispose); + expect(destino.ecualizador.activo, isTrue); + + await destino.importarConfig(exportado); + + expect(destino.ecualizador.activo, isFalse); + }, + ); + + test('regression: other fields still round-trip exactly as before ' + '(ordenListas, timerSuenoPresetsSegundos)', () async { + final origen = await crearRadio(); + addTearDown(origen.dispose); + await origen.guardarTimerSuenoPresetsSegundos([120, 900]); + final exportado = await origen.exportarConfig(); + + final destino = await crearRadio(); + addTearDown(destino.dispose); + await destino.importarConfig(exportado); + + expect(destino.ordenListas.name, origen.ordenListas.name); + }); + }); +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 6f29b5d..cd39ed6 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -414,6 +414,17 @@ class FakeServicioEcualizador extends ServicioEcualizador { ); } + /// Overridden like every other write below: without this, calls fall + /// through to the real `ServicioEcualizador.guardarConfiguracion`, which + /// hits real SharedPreferences and needs a Flutter test binding — a + /// footgun for any test that reaches `EstadoEcualizador.importarConfiguracion` + /// (the backup-import path) through this Fake without also wiring + /// `TestWidgetsFlutterBinding`/mock prefs. + @override + Future guardarConfiguracion(ConfiguracionEcualizador config) async { + _config = config; + } + @override Future guardarPorEmisora(String uuid, PresetEcualizador preset) async { final mapa = Map.from(_config.porEmisora); diff --git a/test/servicios/servicio_export_import_test.dart b/test/servicios/servicio_export_import_test.dart index 2dcb2c2..b0f886d 100644 --- a/test/servicios/servicio_export_import_test.dart +++ b/test/servicios/servicio_export_import_test.dart @@ -232,4 +232,107 @@ void main() { }, ); }); + + group('ServicioExportImport v4 — equalizer on/off toggle', () { + test('v4 export includes ecualizadorActivo=true and schemaVersion 4', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + eqMultiDeviceEnabled: false, + ecualizadorActivo: true, + ); + + expect(config['version'], 4); + expect(config['ecualizadorActivo'], isTrue); + }); + + test('v4 export includes ecualizadorActivo=false and schemaVersion 4', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + eqMultiDeviceEnabled: false, + ecualizadorActivo: false, + ); + + expect(config['version'], 4); + expect(config['ecualizadorActivo'], isFalse); + }); + + test('v4 round-trip preserves the equalizer toggle exactly', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.jazz, + presetsPorEmisora: {'fav-1': PresetEcualizador.rock}, + alarmas: alarmasFixture, + emisoraPreferidaUuid: 'fav-1', + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300, 1800], + eqMultiDeviceEnabled: true, + ecualizadorActivo: false, + ); + + final json = servicio.exportar(config); + final importado = servicio.importar(json); + + expect(importado, isNotNull); + expect(importado!['version'], 4); + expect(importado['ecualizadorActivo'], isFalse); + }); + + test('omitting ecualizadorActivo keeps the export at v3 (backward compat) ' + 'and no key is written', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + eqMultiDeviceEnabled: true, + // ecualizadorActivo omitted — emulates a v3 export. + ); + + expect(config['version'], 3); + expect(config.containsKey('ecualizadorActivo'), isFalse); + }); + + test('omitting every v3/v4 extension keeps the export at v2 (backward ' + 'compat)', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + // No v3/v4 extensions at all. + ); + + expect(config['version'], 2); + expect(config.containsKey('ecualizadorActivo'), isFalse); + expect(config.containsKey('eqMultiDeviceEnabled'), isFalse); + }); + }); }