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
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'estado/estado_ecualizador.dart';
|
||||||
import 'estado/estado_radio.dart';
|
import 'estado/estado_radio.dart';
|
||||||
import 'estado/estado_alarmas.dart';
|
import 'estado/estado_alarmas.dart';
|
||||||
import 'estado/estado_idioma.dart';
|
import 'estado/estado_idioma.dart';
|
||||||
@@ -35,6 +36,12 @@ class PluriWaveApp extends StatelessWidget {
|
|||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
|
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<EstadoEcualizador>(
|
||||||
|
create: (context) => context.read<EstadoRadio>().ecualizador,
|
||||||
|
),
|
||||||
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
|
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => EstadoIdioma(sharedPreferences: prefs),
|
create: (_) => EstadoIdioma(sharedPreferences: prefs),
|
||||||
|
|||||||
@@ -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<String, PresetEcualizador> _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<String, PresetEcualizador> 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<void> 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<void> aplicarPresetActivo(PresetEcualizador preset) async {
|
||||||
|
_presetActual = preset;
|
||||||
|
await audio.aplicarPreset(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> 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<void> habilitarPresetPorEmisora(
|
||||||
|
String uuid, {
|
||||||
|
PresetEcualizador? base,
|
||||||
|
bool notificar = true,
|
||||||
|
}) async {
|
||||||
|
final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal;
|
||||||
|
await guardarPresetPorEmisora(uuid, presetBase, notificar: notificar);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deshabilitarPresetPorEmisora(
|
||||||
|
String uuid, {
|
||||||
|
bool notificar = true,
|
||||||
|
}) async {
|
||||||
|
_presetsEmisoraMap.remove(uuid);
|
||||||
|
await servicio.eliminarPorEmisora(uuid);
|
||||||
|
if (_emisoraActualUuid() == uuid) {
|
||||||
|
await aplicarPresetActivo(_presetPrincipal);
|
||||||
|
}
|
||||||
|
if (notificar) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cambiarModoEmisoraActual({required bool usarPropio}) async {
|
||||||
|
final uuid = _emisoraActualUuid();
|
||||||
|
if (uuid == null) return;
|
||||||
|
if (usarPropio) {
|
||||||
|
await habilitarPresetPorEmisora(uuid);
|
||||||
|
} else {
|
||||||
|
await deshabilitarPresetPorEmisora(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cambiarActivo(bool activo) async {
|
||||||
|
_activo = activo;
|
||||||
|
await servicio.guardarActivo(activo);
|
||||||
|
await audio.setEcualizadorActivo(activo);
|
||||||
|
if (activo) {
|
||||||
|
await audio.aplicarPreset(_presetActual);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> cambiarBanda(int index, double db) async {
|
||||||
|
final bandas = List<double>.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<void> importarConfiguracion({
|
||||||
|
required PresetEcualizador principal,
|
||||||
|
required Map<String, PresetEcualizador> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
-174
@@ -16,8 +16,10 @@ import '../l10n/gen/app_localizations.dart';
|
|||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/grupo_favoritos.dart';
|
import '../modelos/grupo_favoritos.dart';
|
||||||
import '../modelos/preset_ecualizador.dart';
|
import '../modelos/preset_ecualizador.dart';
|
||||||
|
import 'estado_ecualizador.dart';
|
||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
import '../servicios/servicio_ecualizador.dart';
|
import '../servicios/servicio_ecualizador.dart';
|
||||||
|
import '../servicios/servicio_export_import.dart';
|
||||||
import '../servicios/servicio_favoritos.dart';
|
import '../servicios/servicio_favoritos.dart';
|
||||||
import '../servicios/servicio_grabacion_radio.dart';
|
import '../servicios/servicio_grabacion_radio.dart';
|
||||||
import '../servicios/servicio_radio.dart';
|
import '../servicios/servicio_radio.dart';
|
||||||
@@ -48,6 +50,11 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
|
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
|
||||||
_prefs = prefs,
|
_prefs = prefs,
|
||||||
_resolverArchivoCustom = resolverArchivoCustom {
|
_resolverArchivoCustom = resolverArchivoCustom {
|
||||||
|
ecualizador = EstadoEcualizador(
|
||||||
|
audio: this.audio,
|
||||||
|
servicio: this.servicioEcualizador,
|
||||||
|
emisoraActualUuid: () => emisoraActual?.uuid,
|
||||||
|
);
|
||||||
timer = ServicioTimer(this.audio);
|
timer = ServicioTimer(this.audio);
|
||||||
_escucharErroresReproduccion();
|
_escucharErroresReproduccion();
|
||||||
_escucharGrabacion();
|
_escucharGrabacion();
|
||||||
@@ -60,7 +67,13 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
final ServicioFavoritos favoritos;
|
final ServicioFavoritos favoritos;
|
||||||
final ServicioRadio radio;
|
final ServicioRadio radio;
|
||||||
final ServicioEcualizador servicioEcualizador;
|
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;
|
final ServicioGrabacionRadio grabacion;
|
||||||
|
static const ServicioExportImport _exportImport = ServicioExportImport();
|
||||||
final SharedPreferences? _prefs;
|
final SharedPreferences? _prefs;
|
||||||
final Future<File> Function()? _resolverArchivoCustom;
|
final Future<File> Function()? _resolverArchivoCustom;
|
||||||
|
|
||||||
@@ -105,12 +118,6 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
List<GrupoFavoritos> _gruposFavoritos = [];
|
List<GrupoFavoritos> _gruposFavoritos = [];
|
||||||
List<Emisora> _emisorasCustom = [];
|
List<Emisora> _emisorasCustom = [];
|
||||||
|
|
||||||
// Presets EQ guardados por uuid de emisora.
|
|
||||||
final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
|
|
||||||
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
|
|
||||||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
|
||||||
bool _ecualizadorActivo = true;
|
|
||||||
|
|
||||||
bool _cargandoPopulares = false;
|
bool _cargandoPopulares = false;
|
||||||
bool _cargandoBusqueda = false;
|
bool _cargandoBusqueda = false;
|
||||||
bool _cargandoMasBusqueda = false;
|
bool _cargandoMasBusqueda = false;
|
||||||
@@ -164,10 +171,15 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
|
Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
|
||||||
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
|
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
|
||||||
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
||||||
PresetEcualizador get presetEcualizador => _presetActual;
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal;
|
PresetEcualizador get presetEcualizador => ecualizador.presetActual;
|
||||||
bool get ecualizadorActivo => _ecualizadorActivo;
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
bool get ecualizadorDisponible => audio.ecualizadorDisponible;
|
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;
|
OrdenEmisoras get ordenListas => _ordenListas;
|
||||||
List<int> get timerSuenoPresetsSegundos =>
|
List<int> get timerSuenoPresetsSegundos =>
|
||||||
List<int>.unmodifiable(_timerSuenoPresetsSegundos);
|
List<int>.unmodifiable(_timerSuenoPresetsSegundos);
|
||||||
@@ -178,11 +190,9 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
return _listaFavoritos.any((e) => e.uuid == actual.uuid);
|
return _listaFavoritos.any((e) => e.uuid == actual.uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get emisoraActualTienePresetPropio {
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
final actual = emisoraActual;
|
bool get emisoraActualTienePresetPropio =>
|
||||||
if (actual == null) return false;
|
ecualizador.emisoraActualTienePresetPropio;
|
||||||
return tienePresetEcualizadorPorEmisora(actual.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
EstadoGrabacionRadio get estadoGrabacion => grabacion.estado;
|
EstadoGrabacionRadio get estadoGrabacion => grabacion.estado;
|
||||||
bool get grabacionActiva => grabacion.estado.activa;
|
bool get grabacionActiva => grabacion.estado.activa;
|
||||||
@@ -232,7 +242,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
await grabacion.inicializar();
|
await grabacion.inicializar();
|
||||||
await _cargarEcualizadorPersistido();
|
await ecualizador.cargarPersistido();
|
||||||
await _cargarOrdenListas();
|
await _cargarOrdenListas();
|
||||||
await _cargarEmisoraPreferida();
|
await _cargarEmisoraPreferida();
|
||||||
await _cargarTimerSuenoPresets();
|
await _cargarTimerSuenoPresets();
|
||||||
@@ -271,25 +281,6 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _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<void> cargarPopulares() async {
|
Future<void> cargarPopulares() async {
|
||||||
_cargandoPopulares = true;
|
_cargandoPopulares = true;
|
||||||
_errorCarga = null;
|
_errorCarga = null;
|
||||||
@@ -609,7 +600,9 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
await audio.reproducir(emisora);
|
await audio.reproducir(emisora);
|
||||||
if (revision != _revisionReproduccion) return;
|
if (revision != _revisionReproduccion) return;
|
||||||
unawaited(radio.registrarClick(emisora.uuid));
|
unawaited(radio.registrarClick(emisora.uuid));
|
||||||
await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid));
|
await ecualizador.aplicarPresetActivo(
|
||||||
|
ecualizador.presetParaEmisora(emisora.uuid),
|
||||||
|
);
|
||||||
if (revision != _revisionReproduccion) return;
|
if (revision != _revisionReproduccion) return;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -728,126 +721,66 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
|
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
|
||||||
|
|
||||||
// ── Ecualizador ───────────────────────────────────────────────────────────
|
// ── 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) =>
|
bool tienePresetEcualizadorPorEmisora(String uuid) =>
|
||||||
_presetsEmisoraMap.containsKey(uuid);
|
ecualizador.tienePresetPorEmisora(uuid);
|
||||||
|
|
||||||
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
PresetEcualizador? presetEcualizadorPorEmisora(String uuid) =>
|
PresetEcualizador? presetEcualizadorPorEmisora(String uuid) =>
|
||||||
_presetsEmisoraMap[uuid];
|
ecualizador.presetPorEmisora(uuid);
|
||||||
|
|
||||||
PresetEcualizador _presetParaEmisora(String uuid) =>
|
|
||||||
_presetsEmisoraMap[uuid] ?? _presetPrincipal;
|
|
||||||
|
|
||||||
Future<void> _aplicarPresetActivo(PresetEcualizador preset) async {
|
|
||||||
_presetActual = preset;
|
|
||||||
await audio.aplicarPreset(preset);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
Future<void> cambiarPresetPrincipalEcualizador(
|
Future<void> cambiarPresetPrincipalEcualizador(
|
||||||
PresetEcualizador preset, {
|
PresetEcualizador preset, {
|
||||||
bool notificar = true,
|
bool notificar = true,
|
||||||
}) async {
|
}) => ecualizador.cambiarPresetPrincipal(preset, notificar: notificar);
|
||||||
_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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
Future<void> guardarPresetEcualizadorPorEmisora(
|
Future<void> guardarPresetEcualizadorPorEmisora(
|
||||||
String uuid,
|
String uuid,
|
||||||
PresetEcualizador preset, {
|
PresetEcualizador preset, {
|
||||||
bool notificar = true,
|
bool notificar = true,
|
||||||
}) async {
|
}) => ecualizador.guardarPresetPorEmisora(uuid, preset, notificar: notificar);
|
||||||
_presetsEmisoraMap[uuid] = preset;
|
|
||||||
await servicioEcualizador.guardarPorEmisora(uuid, preset);
|
|
||||||
if (emisoraActual?.uuid == uuid) {
|
|
||||||
await _aplicarPresetActivo(preset);
|
|
||||||
}
|
|
||||||
if (notificar) notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
Future<void> habilitarPresetEcualizadorPorEmisora(
|
Future<void> habilitarPresetEcualizadorPorEmisora(
|
||||||
String uuid, {
|
String uuid, {
|
||||||
PresetEcualizador? base,
|
PresetEcualizador? base,
|
||||||
bool notificar = true,
|
bool notificar = true,
|
||||||
}) async {
|
}) => ecualizador.habilitarPresetPorEmisora(
|
||||||
final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal;
|
uuid,
|
||||||
await guardarPresetEcualizadorPorEmisora(
|
base: base,
|
||||||
uuid,
|
notificar: notificar,
|
||||||
presetBase,
|
);
|
||||||
notificar: notificar,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
Future<void> deshabilitarPresetEcualizadorPorEmisora(
|
Future<void> deshabilitarPresetEcualizadorPorEmisora(
|
||||||
String uuid, {
|
String uuid, {
|
||||||
bool notificar = true,
|
bool notificar = true,
|
||||||
}) async {
|
}) => ecualizador.deshabilitarPresetPorEmisora(uuid, notificar: notificar);
|
||||||
_presetsEmisoraMap.remove(uuid);
|
|
||||||
await servicioEcualizador.eliminarPorEmisora(uuid);
|
|
||||||
if (emisoraActual?.uuid == uuid) {
|
|
||||||
await _aplicarPresetActivo(_presetPrincipal);
|
|
||||||
}
|
|
||||||
if (notificar) notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
Future<void> cambiarModoEcualizadorEmisoraActual({
|
Future<void> cambiarModoEcualizadorEmisoraActual({
|
||||||
required bool usarPropio,
|
required bool usarPropio,
|
||||||
}) async {
|
}) => ecualizador.cambiarModoEmisoraActual(usarPropio: usarPropio);
|
||||||
final actual = emisoraActual;
|
|
||||||
if (actual == null) return;
|
|
||||||
if (usarPropio) {
|
|
||||||
await habilitarPresetEcualizadorPorEmisora(actual.uuid);
|
|
||||||
} else {
|
|
||||||
await deshabilitarPresetEcualizadorPorEmisora(actual.uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cambiarEcualizadorActivo(bool activo) async {
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
_ecualizadorActivo = activo;
|
Future<void> cambiarEcualizadorActivo(bool activo) =>
|
||||||
await servicioEcualizador.guardarActivo(activo);
|
ecualizador.cambiarActivo(activo);
|
||||||
await audio.setEcualizadorActivo(activo);
|
|
||||||
if (activo) {
|
|
||||||
await audio.aplicarPreset(_presetActual);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
Future<void> cambiarPresetEcualizador(
|
Future<void> cambiarPresetEcualizador(
|
||||||
PresetEcualizador preset, {
|
PresetEcualizador preset, {
|
||||||
bool guardarPorEmisora = true,
|
bool guardarPorEmisora = true,
|
||||||
}) async {
|
}) => ecualizador.cambiarPreset(preset, guardarPorEmisora: guardarPorEmisora);
|
||||||
final actual = emisoraActual;
|
|
||||||
final usarPresetPropio =
|
|
||||||
guardarPorEmisora &&
|
|
||||||
actual != null &&
|
|
||||||
_presetsEmisoraMap.containsKey(actual.uuid);
|
|
||||||
|
|
||||||
if (usarPresetPropio) {
|
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||||
await guardarPresetEcualizadorPorEmisora(actual.uuid, preset);
|
Future<void> cambiarBandaEcualizador(int index, double db) =>
|
||||||
return;
|
ecualizador.cambiarBanda(index, db);
|
||||||
}
|
|
||||||
await cambiarPresetPrincipalEcualizador(preset);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cambiarBandaEcualizador(int index, double db) async {
|
|
||||||
final bandas = List<double>.from(_presetActual.bandas);
|
|
||||||
if (index < 0 || index >= bandas.length) return;
|
|
||||||
|
|
||||||
bandas[index] = db;
|
|
||||||
final modificado = PresetEcualizador(
|
|
||||||
nombre: 'Personalizado',
|
|
||||||
bandas: bandas,
|
|
||||||
);
|
|
||||||
await cambiarPresetEcualizador(modificado);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Emisoras personalizadas ───────────────────────────────────────────────
|
// ── Emisoras personalizadas ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -912,6 +845,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
|
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
|
||||||
|
|
||||||
/// Genera el JSON de toda la configuración (v2 — portabilidad completa).
|
/// Genera el JSON de toda la configuración (v2 — portabilidad completa).
|
||||||
|
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4).
|
||||||
Future<Map<String, dynamic>> exportarConfig() async {
|
Future<Map<String, dynamic>> exportarConfig() async {
|
||||||
final favs = await favoritos.obtenerTodos();
|
final favs = await favoritos.obtenerTodos();
|
||||||
final grupos = await favoritos.obtenerGrupos();
|
final grupos = await favoritos.obtenerGrupos();
|
||||||
@@ -925,29 +859,27 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
? jsonDecode(alarmasRaw) as Map<String, dynamic>
|
? jsonDecode(alarmasRaw) as Map<String, dynamic>
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return _exportImport.construirExportacion(
|
||||||
'version': 2,
|
gruposFavoritos: grupos,
|
||||||
'exportedAt': DateTime.now().toIso8601String(),
|
favoritos: favs,
|
||||||
// Favoritos + grupos (preserva asignaciones grupo_id en cada emisora)
|
emisorasCustom: _emisorasCustom,
|
||||||
'gruposFavoritos':
|
presetPrincipal: ecualizador.presetPrincipal,
|
||||||
grupos.where((g) => !g.esSinAsignar).map((g) => g.toMap()).toList(),
|
presetsPorEmisora: ecualizador.presetsPorEmisora,
|
||||||
'favoritos': favs.map((e) => e.toMap()).toList(),
|
alarmas: alarmasData,
|
||||||
// Emisoras personalizadas
|
emisoraPreferidaUuid: _emisoraPreferidaUuid,
|
||||||
'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(),
|
ordenListas: _ordenListas.name,
|
||||||
// Ecualizador
|
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
|
||||||
'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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Importa configuración desde un JSON exportado previamente.
|
||||||
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa).
|
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa).
|
||||||
Future<void> importarConfig(Map<String, dynamic> data) async {
|
Future<void> importarConfig(Map<String, dynamic> data) async {
|
||||||
@@ -985,40 +917,27 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
|
|
||||||
// ── Ecualizador ───────────────────────────────────────────────────────
|
// ── Ecualizador ───────────────────────────────────────────────────────
|
||||||
final principalRaw = data['presetPrincipalEcualizador'];
|
final principalRaw = data['presetPrincipalEcualizador'];
|
||||||
if (principalRaw is Map) {
|
final presetPrincipal =
|
||||||
_presetPrincipal = PresetEcualizador.desdeJson(
|
principalRaw is Map
|
||||||
Map<String, dynamic>.from(principalRaw),
|
? PresetEcualizador.desdeJson(
|
||||||
);
|
Map<String, dynamic>.from(principalRaw),
|
||||||
} else {
|
)
|
||||||
_presetPrincipal = PresetEcualizador.flat;
|
: PresetEcualizador.flat;
|
||||||
}
|
|
||||||
|
|
||||||
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
|
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
|
||||||
_presetsEmisoraMap
|
final presetsPorEmisora = presetsRaw.map<String, PresetEcualizador>(
|
||||||
..clear()
|
(uuid, presetJson) => MapEntry(
|
||||||
..addAll(
|
uuid as String,
|
||||||
presetsRaw.map<String, PresetEcualizador>(
|
PresetEcualizador.desdeJson(
|
||||||
(uuid, presetJson) => MapEntry(
|
Map<String, dynamic>.from(presetJson as Map),
|
||||||
uuid as String,
|
|
||||||
PresetEcualizador.desdeJson(
|
|
||||||
Map<String, dynamic>.from(presetJson as Map),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
|
|
||||||
await servicioEcualizador.guardarConfiguracion(
|
|
||||||
ConfiguracionEcualizador(
|
|
||||||
principal: _presetPrincipal,
|
|
||||||
porEmisora: _presetsEmisoraMap,
|
|
||||||
activo: _ecualizadorActivo,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final actual = emisoraActual;
|
await ecualizador.importarConfiguracion(
|
||||||
final presetActivo =
|
principal: presetPrincipal,
|
||||||
actual == null ? _presetPrincipal : _presetParaEmisora(actual.uuid);
|
porEmisora: presetsPorEmisora,
|
||||||
await _aplicarPresetActivo(presetActivo);
|
);
|
||||||
|
|
||||||
// ── Alarmas (v2) ──────────────────────────────────────────────────────
|
// ── Alarmas (v2) ──────────────────────────────────────────────────────
|
||||||
if (version >= 2) {
|
if (version >= 2) {
|
||||||
@@ -1122,6 +1041,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_suscripcionEstadoAudio?.cancel();
|
_suscripcionEstadoAudio?.cancel();
|
||||||
_suscripcionGrabacion?.cancel();
|
_suscripcionGrabacion?.cancel();
|
||||||
_errorController.close();
|
_errorController.close();
|
||||||
|
ecualizador.dispose();
|
||||||
audio.dispose();
|
audio.dispose();
|
||||||
unawaited(grabacion.dispose());
|
unawaited(grabacion.dispose());
|
||||||
timer.dispose();
|
timer.dispose();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
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:share_plus/share_plus.dart' show Share, XFile;
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../estado/estado_ecualizador.dart';
|
||||||
import '../estado/estado_idioma.dart';
|
import '../estado/estado_idioma.dart';
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
import '../l10n/display_names.dart';
|
import '../l10n/display_names.dart';
|
||||||
@@ -336,10 +336,7 @@ class _SeccionTimerSueno extends StatelessWidget {
|
|||||||
for (final segundos in presets)
|
for (final segundos in presets)
|
||||||
InputChip(
|
InputChip(
|
||||||
label: Text(
|
label: Text(
|
||||||
_formatearDuracionTimer(
|
_formatearDuracionTimer(l10n, Duration(seconds: segundos)),
|
||||||
l10n,
|
|
||||||
Duration(seconds: segundos),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
onDeleted:
|
onDeleted:
|
||||||
presets.length <= 1
|
presets.length <= 1
|
||||||
@@ -574,14 +571,16 @@ class _SeccionEcualizador extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<EstadoRadio>(
|
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
|
||||||
builder: (ctx, estado, _) {
|
// only consulted for the current station + favorite flag.
|
||||||
final disponible = estado.ecualizadorDisponible;
|
return Consumer2<EstadoRadio, EstadoEcualizador>(
|
||||||
|
builder: (ctx, estado, eq, _) {
|
||||||
|
final disponible = eq.disponible;
|
||||||
final l10n = AppLocalizations.of(ctx);
|
final l10n = AppLocalizations.of(ctx);
|
||||||
final emisoraActual = estado.emisoraActual;
|
final emisoraActual = estado.emisoraActual;
|
||||||
final mostrarModoPorEmisora =
|
final mostrarModoPorEmisora =
|
||||||
emisoraActual != null && estado.emisoraActualEsFavorita;
|
emisoraActual != null && estado.emisoraActualEsFavorita;
|
||||||
final usandoEqPropio = estado.emisoraActualTienePresetPropio;
|
final usandoEqPropio = eq.emisoraActualTienePresetPropio;
|
||||||
|
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -598,9 +597,7 @@ class _SeccionEcualizador extends StatelessWidget {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
Chip(
|
Chip(
|
||||||
label: Text(
|
label: Text(
|
||||||
estado.ecualizadorActivo
|
eq.activo ? l10n.equalizerActive : l10n.equalizerDisabled,
|
||||||
? l10n.equalizerActive
|
|
||||||
: l10n.equalizerDisabled,
|
|
||||||
),
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
@@ -615,8 +612,8 @@ class _SeccionEcualizador extends StatelessWidget {
|
|||||||
? l10n.equalizerRealtimeSubtitle
|
? l10n.equalizerRealtimeSubtitle
|
||||||
: l10n.equalizerPendingSubtitle,
|
: l10n.equalizerPendingSubtitle,
|
||||||
),
|
),
|
||||||
value: estado.ecualizadorActivo,
|
value: eq.activo,
|
||||||
onChanged: estado.cambiarEcualizadorActivo,
|
onChanged: eq.cambiarActivo,
|
||||||
),
|
),
|
||||||
if (mostrarModoPorEmisora) ...[
|
if (mostrarModoPorEmisora) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -631,20 +628,18 @@ class _SeccionEcualizador extends StatelessWidget {
|
|||||||
value: usandoEqPropio,
|
value: usandoEqPropio,
|
||||||
onChanged:
|
onChanged:
|
||||||
(usarPropio) =>
|
(usarPropio) =>
|
||||||
estado.cambiarModoEcualizadorEmisoraActual(
|
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
|
||||||
usarPropio: usarPropio,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
PresetsEcualizadorWidget(
|
PresetsEcualizadorWidget(
|
||||||
presetActual: estado.presetEcualizador,
|
presetActual: eq.presetActual,
|
||||||
onSeleccionar: (p) => estado.cambiarPresetEcualizador(p),
|
onSeleccionar: (p) => eq.cambiarPreset(p),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
EcualizadorWidget(
|
EcualizadorWidget(
|
||||||
preset: estado.presetEcualizador,
|
preset: eq.presetActual,
|
||||||
onCambio: (p) => estado.cambiarPresetEcualizador(p),
|
onCambio: (p) => eq.cambiarPreset(p),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1176,8 +1171,8 @@ class _SeccionBackup extends StatelessWidget {
|
|||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
try {
|
try {
|
||||||
final estado = context.read<EstadoRadio>();
|
final estado = context.read<EstadoRadio>();
|
||||||
final config = await estado.exportarConfig();
|
// JSON serialization is owned by ServicioExportImport (S4-R4).
|
||||||
final json = const JsonEncoder.withIndent(' ').convert(config);
|
final json = await estado.exportarConfigJson();
|
||||||
|
|
||||||
final dir = await getTemporaryDirectory();
|
final dir = await getTemporaryDirectory();
|
||||||
final file = File('${dir.path}/pluriwave-backup.json');
|
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;
|
if (result == null || result.files.single.path == null) return;
|
||||||
|
|
||||||
final file = File(result.files.single.path!);
|
final file = File(result.files.single.path!);
|
||||||
final json =
|
if (!context.mounted) return;
|
||||||
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
|
||||||
|
final json = context.read<EstadoRadio>().parsearConfigJson(
|
||||||
|
await file.readAsString(),
|
||||||
|
);
|
||||||
|
if (json == null) {
|
||||||
|
throw const FormatException('invalid backup file');
|
||||||
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final confirmar = await showDialog<bool>(
|
final confirmar = await showDialog<bool>(
|
||||||
@@ -1365,10 +1366,7 @@ class _SeccionInfo extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatearDuracionTimer(
|
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
|
||||||
AppLocalizations l10n,
|
|
||||||
Duration duracion,
|
|
||||||
) {
|
|
||||||
final horas = duracion.inHours;
|
final horas = duracion.inHours;
|
||||||
final minutos = duracion.inMinutes.remainder(60);
|
final minutos = duracion.inMinutes.remainder(60);
|
||||||
final segundos = duracion.inSeconds.remainder(60);
|
final segundos = duracion.inSeconds.remainder(60);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
|
import '../estado/estado_ecualizador.dart';
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
@@ -76,6 +77,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final tokens = context.pluriTokens;
|
final tokens = context.pluriTokens;
|
||||||
final estado = context.watch<EstadoRadio>();
|
final estado = context.watch<EstadoRadio>();
|
||||||
|
// EQ toggle state lives in EstadoEcualizador (S4-R1); EstadoRadio no
|
||||||
|
// longer notifies on EQ changes, so this screen watches both.
|
||||||
|
final eq = context.watch<EstadoEcualizador>();
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final emisoraActiva = estado.emisoraActual ?? widget.emisora;
|
final emisoraActiva = estado.emisoraActual ?? widget.emisora;
|
||||||
final esFavorito = estado.listaFavoritos.any(
|
final esFavorito = estado.listaFavoritos.any(
|
||||||
@@ -94,18 +98,11 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
estado.ecualizadorActivo
|
eq.activo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
||||||
? Icons.equalizer_rounded
|
color: eq.activo ? tokens.warmCoral : null,
|
||||||
: Icons.equalizer_outlined,
|
|
||||||
color: estado.ecualizadorActivo ? tokens.warmCoral : null,
|
|
||||||
),
|
),
|
||||||
tooltip:
|
tooltip: eq.activo ? l10n.equalizerDisable : l10n.equalizerEnable,
|
||||||
estado.ecualizadorActivo
|
onPressed: () => eq.cambiarActivo(!eq.activo),
|
||||||
? l10n.equalizerDisable
|
|
||||||
: l10n.equalizerEnable,
|
|
||||||
onPressed:
|
|
||||||
() =>
|
|
||||||
estado.cambiarEcualizadorActivo(!estado.ecualizadorActivo),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|||||||
@@ -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<String, dynamic> construirExportacion({
|
||||||
|
required List<GrupoFavoritos> gruposFavoritos,
|
||||||
|
required List<Emisora> favoritos,
|
||||||
|
required List<Emisora> emisorasCustom,
|
||||||
|
required PresetEcualizador presetPrincipal,
|
||||||
|
required Map<String, PresetEcualizador> presetsPorEmisora,
|
||||||
|
required Map<String, dynamic>? alarmas,
|
||||||
|
required String? emisoraPreferidaUuid,
|
||||||
|
required String ordenListas,
|
||||||
|
required List<int> 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<String, dynamic> 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<String, dynamic>? importar(String raw) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map) return null;
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
**Mode**: Strict TDD (test runner: `flutter test`)
|
**Mode**: Strict TDD (test runner: `flutter test`)
|
||||||
**Artifact store**: openspec (Engram unavailable this session)
|
**Artifact store**: openspec (Engram unavailable this session)
|
||||||
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
|
**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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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)
|
## Task status (cumulative)
|
||||||
|
|
||||||
@@ -130,9 +131,24 @@
|
|||||||
| T-S7-12 | [x] | `flutter analyze` — No issues found |
|
| 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 |
|
| 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<EstadoRadio, EstadoEcualizador>`; `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)
|
### 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)
|
## 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).
|
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)
|
## Files changed (Batch 2)
|
||||||
|
|
||||||
| File | Action | ~Lines |
|
| 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.
|
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<EstadoEcualizador> 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<String, dynamic>?`, 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<EstadoRadio>().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<EstadoRadio>` 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)
|
## 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).
|
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.
|
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.
|
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
|
## Workload / boundary
|
||||||
|
|
||||||
- Mode: auto-chain local slices (no PRs)
|
- Mode: auto-chain local slices (no PRs)
|
||||||
- Current work units: S1, S2a, S2b, S3a, S3b (committed f3e9487, 079e19f), S7 (complete, in working tree)
|
- Current work units: S1, S2a, S2b, S3a, S3b, S7 (committed, latest 0380bbb), S4a (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).
|
- 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: S4a (ServicioExportImport + EstadoEcualizador extraction). No dependency on S7; on-device items above can be verified in parallel.
|
- 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).
|
||||||
|
|||||||
@@ -294,28 +294,28 @@ Chain strategy: N/A (local apply)
|
|||||||
|
|
||||||
### S4a pre-work: write failing tests
|
### 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 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)
|
- Test B: malformed JSON input to `importar()` → graceful empty result, no throw. (S4-R4)
|
||||||
**~40 lines.**
|
**DONE — round-trip also locks alarmas raw passthrough + "sin asignar" group never exported; malformed cases: invalid JSON, empty string, non-object JSON.**
|
||||||
- [ ] **T-S4a-02** [RED] Create `test/estado/estado_ecualizador_test.dart`:
|
- [x] **T-S4a-02** [RED] Create `test/estado/estado_ecualizador_test.dart`:
|
||||||
- Test A: `aplicarPreset` notifies `EstadoEcualizador` listeners. (S4-R1-A)
|
- 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)
|
- 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
|
### 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.**
|
- [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<String,dynamic>` instead of a `ConfiguracionCompleta` model (see apply-progress).**
|
||||||
- [ ] **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.**
|
- [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.**
|
||||||
- [ ] **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.**
|
- [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.**
|
||||||
- [ ] **T-S4a-06** [GREEN] Edit `lib/widgets/ecualizador_widget.dart` and `lib/pantallas/pantalla_ajustes.dart` EQ sections: consume `context.watch<EstadoEcualizador>()` (scoped). Screens still compile via compat getters if missed. **Reqs:** S4-R5. **~20 lines.**
|
- [x] **T-S4a-06** [GREEN] Edit `lib/widgets/ecualizador_widget.dart` and `lib/pantallas/pantalla_ajustes.dart` EQ sections: consume `context.watch<EstadoEcualizador>()` (scoped). Screens still compile via compat getters if missed. **Reqs:** S4-R5. **DONE — `_SeccionEcualizador` now `Consumer2<EstadoRadio, EstadoEcualizador>` (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
|
### S4a verification
|
||||||
|
|
||||||
- [ ] **T-S4a-07** Run `flutter test test/servicios/servicio_export_import_test.dart test/estado/estado_ecualizador_test.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).
|
||||||
- [ ] **T-S4a-08** Run `flutter test` (full suite) — no regressions.
|
- [x] **T-S4a-08** Run `flutter test` (full suite) — 103/103 passing (99 baseline + 4 new), no regressions.
|
||||||
- [ ] **T-S4a-09** Run `flutter analyze` — zero errors.
|
- [x] **T-S4a-09** Run `flutter analyze` — `No issues found!`.
|
||||||
- [ ] **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-10** Run `dart format` on all 8 touched Dart files (4 reflowed); analyze + full suite re-run after format.
|
||||||
|
|
||||||
### S4a Definition of Done
|
### S4a Definition of Done
|
||||||
- `flutter test` green.
|
- `flutter test` green.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user