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:
2026-06-11 21:16:30 +02:00
parent 0380bbb1e7
commit 0416b301b2
10 changed files with 637 additions and 231 deletions
+7
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'estado/estado_ecualizador.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
@@ -35,6 +36,12 @@ class PluriWaveApp extends StatelessWidget {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
// EQ notifier (S4-R1). Created and disposed by EstadoRadio during
// the S4 transition; this provider only exposes the instance, so it
// declares no dispose callback.
ListenableProvider<EstadoEcualizador>(
create: (context) => context.read<EstadoRadio>().ecualizador,
),
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
ChangeNotifierProvider(
create: (_) => EstadoIdioma(sharedPreferences: prefs),
+207
View File
@@ -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
View File
@@ -16,8 +16,10 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart';
import 'estado_ecualizador.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_export_import.dart';
import '../servicios/servicio_favoritos.dart';
import '../servicios/servicio_grabacion_radio.dart';
import '../servicios/servicio_radio.dart';
@@ -48,6 +50,11 @@ class EstadoRadio extends ChangeNotifier {
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
_prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom {
ecualizador = EstadoEcualizador(
audio: this.audio,
servicio: this.servicioEcualizador,
emisoraActualUuid: () => emisoraActual?.uuid,
);
timer = ServicioTimer(this.audio);
_escucharErroresReproduccion();
_escucharGrabacion();
@@ -60,7 +67,13 @@ class EstadoRadio extends ChangeNotifier {
final ServicioFavoritos favoritos;
final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador;
/// EQ state extracted to its own notifier (S4-R1). Owned (and disposed)
/// by EstadoRadio during the S4 transition; exposed app-wide through a
/// ListenableProvider in app.dart.
late final EstadoEcualizador ecualizador;
final ServicioGrabacionRadio grabacion;
static const ServicioExportImport _exportImport = ServicioExportImport();
final SharedPreferences? _prefs;
final Future<File> Function()? _resolverArchivoCustom;
@@ -105,12 +118,6 @@ class EstadoRadio extends ChangeNotifier {
List<GrupoFavoritos> _gruposFavoritos = [];
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 _cargandoBusqueda = false;
bool _cargandoMasBusqueda = false;
@@ -164,10 +171,15 @@ class EstadoRadio extends ChangeNotifier {
Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
PresetEcualizador get presetEcualizador => _presetActual;
PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal;
bool get ecualizadorActivo => _ecualizadorActivo;
bool get ecualizadorDisponible => audio.ecualizadorDisponible;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
PresetEcualizador get presetEcualizador => ecualizador.presetActual;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
PresetEcualizador get presetPrincipalEcualizador =>
ecualizador.presetPrincipal;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool get ecualizadorActivo => ecualizador.activo;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool get ecualizadorDisponible => ecualizador.disponible;
OrdenEmisoras get ordenListas => _ordenListas;
List<int> get timerSuenoPresetsSegundos =>
List<int>.unmodifiable(_timerSuenoPresetsSegundos);
@@ -178,11 +190,9 @@ class EstadoRadio extends ChangeNotifier {
return _listaFavoritos.any((e) => e.uuid == actual.uuid);
}
bool get emisoraActualTienePresetPropio {
final actual = emisoraActual;
if (actual == null) return false;
return tienePresetEcualizadorPorEmisora(actual.uuid);
}
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool get emisoraActualTienePresetPropio =>
ecualizador.emisoraActualTienePresetPropio;
EstadoGrabacionRadio get estadoGrabacion => grabacion.estado;
bool get grabacionActiva => grabacion.estado.activa;
@@ -232,7 +242,7 @@ class EstadoRadio extends ChangeNotifier {
Future<void> _init() async {
await grabacion.inicializar();
await _cargarEcualizadorPersistido();
await ecualizador.cargarPersistido();
await _cargarOrdenListas();
await _cargarEmisoraPreferida();
await _cargarTimerSuenoPresets();
@@ -271,25 +281,6 @@ class EstadoRadio extends ChangeNotifier {
});
}
Future<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 {
_cargandoPopulares = true;
_errorCarga = null;
@@ -609,7 +600,9 @@ class EstadoRadio extends ChangeNotifier {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
unawaited(radio.registrarClick(emisora.uuid));
await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid));
await ecualizador.aplicarPresetActivo(
ecualizador.presetParaEmisora(emisora.uuid),
);
if (revision != _revisionReproduccion) return;
notifyListeners();
} catch (e) {
@@ -728,126 +721,66 @@ class EstadoRadio extends ChangeNotifier {
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
// ── Ecualizador ───────────────────────────────────────────────────────────
// Transition bridge (S4a): EQ state lives in EstadoEcualizador; these
// delegating members keep legacy call sites compiling. They do NOT notify
// EstadoRadio listeners (S4-R1-A).
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool tienePresetEcualizadorPorEmisora(String uuid) =>
_presetsEmisoraMap.containsKey(uuid);
ecualizador.tienePresetPorEmisora(uuid);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
PresetEcualizador? presetEcualizadorPorEmisora(String uuid) =>
_presetsEmisoraMap[uuid];
PresetEcualizador _presetParaEmisora(String uuid) =>
_presetsEmisoraMap[uuid] ?? _presetPrincipal;
Future<void> _aplicarPresetActivo(PresetEcualizador preset) async {
_presetActual = preset;
await audio.aplicarPreset(preset);
}
ecualizador.presetPorEmisora(uuid);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarPresetPrincipalEcualizador(
PresetEcualizador preset, {
bool notificar = true,
}) async {
_presetPrincipal = preset;
await servicioEcualizador.guardarPrincipal(preset);
final actual = emisoraActual;
final puedeAplicarAhora =
actual == null || !_presetsEmisoraMap.containsKey(actual.uuid);
if (puedeAplicarAhora) {
await _aplicarPresetActivo(preset);
}
if (notificar) notifyListeners();
}
}) => ecualizador.cambiarPresetPrincipal(preset, notificar: notificar);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> guardarPresetEcualizadorPorEmisora(
String uuid,
PresetEcualizador preset, {
bool notificar = true,
}) async {
_presetsEmisoraMap[uuid] = preset;
await servicioEcualizador.guardarPorEmisora(uuid, preset);
if (emisoraActual?.uuid == uuid) {
await _aplicarPresetActivo(preset);
}
if (notificar) notifyListeners();
}
}) => ecualizador.guardarPresetPorEmisora(uuid, preset, notificar: notificar);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> habilitarPresetEcualizadorPorEmisora(
String uuid, {
PresetEcualizador? base,
bool notificar = true,
}) async {
final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal;
await guardarPresetEcualizadorPorEmisora(
uuid,
presetBase,
notificar: notificar,
);
}
}) => ecualizador.habilitarPresetPorEmisora(
uuid,
base: base,
notificar: notificar,
);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> deshabilitarPresetEcualizadorPorEmisora(
String uuid, {
bool notificar = true,
}) async {
_presetsEmisoraMap.remove(uuid);
await servicioEcualizador.eliminarPorEmisora(uuid);
if (emisoraActual?.uuid == uuid) {
await _aplicarPresetActivo(_presetPrincipal);
}
if (notificar) notifyListeners();
}
}) => ecualizador.deshabilitarPresetPorEmisora(uuid, notificar: notificar);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarModoEcualizadorEmisoraActual({
required bool usarPropio,
}) async {
final actual = emisoraActual;
if (actual == null) return;
if (usarPropio) {
await habilitarPresetEcualizadorPorEmisora(actual.uuid);
} else {
await deshabilitarPresetEcualizadorPorEmisora(actual.uuid);
}
}
}) => ecualizador.cambiarModoEmisoraActual(usarPropio: usarPropio);
Future<void> cambiarEcualizadorActivo(bool activo) async {
_ecualizadorActivo = activo;
await servicioEcualizador.guardarActivo(activo);
await audio.setEcualizadorActivo(activo);
if (activo) {
await audio.aplicarPreset(_presetActual);
}
notifyListeners();
}
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarEcualizadorActivo(bool activo) =>
ecualizador.cambiarActivo(activo);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarPresetEcualizador(
PresetEcualizador preset, {
bool guardarPorEmisora = true,
}) async {
final actual = emisoraActual;
final usarPresetPropio =
guardarPorEmisora &&
actual != null &&
_presetsEmisoraMap.containsKey(actual.uuid);
}) => ecualizador.cambiarPreset(preset, guardarPorEmisora: guardarPorEmisora);
if (usarPresetPropio) {
await guardarPresetEcualizadorPorEmisora(actual.uuid, preset);
return;
}
await cambiarPresetPrincipalEcualizador(preset);
}
Future<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);
}
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarBandaEcualizador(int index, double db) =>
ecualizador.cambiarBanda(index, db);
// ── Emisoras personalizadas ───────────────────────────────────────────────
@@ -912,6 +845,7 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v2 — portabilidad completa).
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos();
@@ -925,29 +859,27 @@ class EstadoRadio extends ChangeNotifier {
? jsonDecode(alarmasRaw) as Map<String, dynamic>
: null;
return {
'version': 2,
'exportedAt': DateTime.now().toIso8601String(),
// Favoritos + grupos (preserva asignaciones grupo_id en cada emisora)
'gruposFavoritos':
grupos.where((g) => !g.esSinAsignar).map((g) => g.toMap()).toList(),
'favoritos': favs.map((e) => e.toMap()).toList(),
// Emisoras personalizadas
'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(),
// Ecualizador
'presetPrincipalEcualizador': _presetPrincipal.toJson(),
'presetsEcualizador': _presetsEmisoraMap.map(
(uuid, preset) => MapEntry(uuid, preset.toJson()),
),
// Alarmas completas (alarmas + vacaciones + excepciones)
'alarmas': alarmasData,
// Preferencias de usuario
'emisoraPreferidaUuid': _emisoraPreferidaUuid,
'ordenListas': _ordenListas.name,
'timerSuenoPresetsSegundos': _timerSuenoPresetsSegundos,
};
return _exportImport.construirExportacion(
gruposFavoritos: grupos,
favoritos: favs,
emisorasCustom: _emisorasCustom,
presetPrincipal: ecualizador.presetPrincipal,
presetsPorEmisora: ecualizador.presetsPorEmisora,
alarmas: alarmasData,
emisoraPreferidaUuid: _emisoraPreferidaUuid,
ordenListas: _ordenListas.name,
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
);
}
/// Exportación lista para compartir como archivo (JSON con indentación).
Future<String> exportarConfigJson() async =>
_exportImport.exportar(await exportarConfig());
/// Parsea un backup JSON; null cuando el contenido no es válido (S4-R4).
Map<String, dynamic>? parsearConfigJson(String raw) =>
_exportImport.importar(raw);
/// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa).
Future<void> importarConfig(Map<String, dynamic> data) async {
@@ -985,40 +917,27 @@ class EstadoRadio extends ChangeNotifier {
// ── Ecualizador ───────────────────────────────────────────────────────
final principalRaw = data['presetPrincipalEcualizador'];
if (principalRaw is Map) {
_presetPrincipal = PresetEcualizador.desdeJson(
Map<String, dynamic>.from(principalRaw),
);
} else {
_presetPrincipal = PresetEcualizador.flat;
}
final presetPrincipal =
principalRaw is Map
? PresetEcualizador.desdeJson(
Map<String, dynamic>.from(principalRaw),
)
: PresetEcualizador.flat;
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
_presetsEmisoraMap
..clear()
..addAll(
presetsRaw.map<String, PresetEcualizador>(
(uuid, presetJson) => MapEntry(
uuid as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
final presetsPorEmisora = presetsRaw.map<String, PresetEcualizador>(
(uuid, presetJson) => MapEntry(
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;
final presetActivo =
actual == null ? _presetPrincipal : _presetParaEmisora(actual.uuid);
await _aplicarPresetActivo(presetActivo);
await ecualizador.importarConfiguracion(
principal: presetPrincipal,
porEmisora: presetsPorEmisora,
);
// ── Alarmas (v2) ──────────────────────────────────────────────────────
if (version >= 2) {
@@ -1122,6 +1041,7 @@ class EstadoRadio extends ChangeNotifier {
_suscripcionEstadoAudio?.cancel();
_suscripcionGrabacion?.cancel();
_errorController.close();
ecualizador.dispose();
audio.dispose();
unawaited(grabacion.dispose());
timer.dispose();
+27 -29
View File
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
@@ -9,6 +8,7 @@ import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import 'package:uuid/uuid.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
@@ -336,10 +336,7 @@ class _SeccionTimerSueno extends StatelessWidget {
for (final segundos in presets)
InputChip(
label: Text(
_formatearDuracionTimer(
l10n,
Duration(seconds: segundos),
),
_formatearDuracionTimer(l10n, Duration(seconds: segundos)),
),
onDeleted:
presets.length <= 1
@@ -574,14 +571,16 @@ class _SeccionEcualizador extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<EstadoRadio>(
builder: (ctx, estado, _) {
final disponible = estado.ecualizadorDisponible;
// EQ state comes from EstadoEcualizador (S4-R1/S4-R5); EstadoRadio is
// only consulted for the current station + favorite flag.
return Consumer2<EstadoRadio, EstadoEcualizador>(
builder: (ctx, estado, eq, _) {
final disponible = eq.disponible;
final l10n = AppLocalizations.of(ctx);
final emisoraActual = estado.emisoraActual;
final mostrarModoPorEmisora =
emisoraActual != null && estado.emisoraActualEsFavorita;
final usandoEqPropio = estado.emisoraActualTienePresetPropio;
final usandoEqPropio = eq.emisoraActualTienePresetPropio;
return PluriGlassSurface(
child: Column(
@@ -598,9 +597,7 @@ class _SeccionEcualizador extends StatelessWidget {
const Spacer(),
Chip(
label: Text(
estado.ecualizadorActivo
? l10n.equalizerActive
: l10n.equalizerDisabled,
eq.activo ? l10n.equalizerActive : l10n.equalizerDisabled,
),
visualDensity: VisualDensity.compact,
),
@@ -615,8 +612,8 @@ class _SeccionEcualizador extends StatelessWidget {
? l10n.equalizerRealtimeSubtitle
: l10n.equalizerPendingSubtitle,
),
value: estado.ecualizadorActivo,
onChanged: estado.cambiarEcualizadorActivo,
value: eq.activo,
onChanged: eq.cambiarActivo,
),
if (mostrarModoPorEmisora) ...[
const SizedBox(height: 8),
@@ -631,20 +628,18 @@ class _SeccionEcualizador extends StatelessWidget {
value: usandoEqPropio,
onChanged:
(usarPropio) =>
estado.cambiarModoEcualizadorEmisoraActual(
usarPropio: usarPropio,
),
eq.cambiarModoEmisoraActual(usarPropio: usarPropio),
),
],
const SizedBox(height: 8),
PresetsEcualizadorWidget(
presetActual: estado.presetEcualizador,
onSeleccionar: (p) => estado.cambiarPresetEcualizador(p),
presetActual: eq.presetActual,
onSeleccionar: (p) => eq.cambiarPreset(p),
),
const SizedBox(height: 12),
EcualizadorWidget(
preset: estado.presetEcualizador,
onCambio: (p) => estado.cambiarPresetEcualizador(p),
preset: eq.presetActual,
onCambio: (p) => eq.cambiarPreset(p),
),
],
),
@@ -1176,8 +1171,8 @@ class _SeccionBackup extends StatelessWidget {
final l10n = AppLocalizations.of(context);
try {
final estado = context.read<EstadoRadio>();
final config = await estado.exportarConfig();
final json = const JsonEncoder.withIndent(' ').convert(config);
// JSON serialization is owned by ServicioExportImport (S4-R4).
final json = await estado.exportarConfigJson();
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pluriwave-backup.json');
@@ -1207,8 +1202,14 @@ class _SeccionBackup extends StatelessWidget {
if (result == null || result.files.single.path == null) return;
final file = File(result.files.single.path!);
final json =
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
if (!context.mounted) return;
// 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) {
final confirmar = await showDialog<bool>(
@@ -1365,10 +1366,7 @@ class _SeccionInfo extends StatelessWidget {
}
}
String _formatearDuracionTimer(
AppLocalizations l10n,
Duration duracion,
) {
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
+8 -11
View File
@@ -4,6 +4,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
@@ -76,6 +77,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
final theme = Theme.of(context);
final tokens = context.pluriTokens;
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 emisoraActiva = estado.emisoraActual ?? widget.emisora;
final esFavorito = estado.listaFavoritos.any(
@@ -94,18 +98,11 @@ class _PantallaReproductorState extends State<PantallaReproductor>
actions: [
IconButton(
icon: Icon(
estado.ecualizadorActivo
? Icons.equalizer_rounded
: Icons.equalizer_outlined,
color: estado.ecualizadorActivo ? tokens.warmCoral : null,
eq.activo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
color: eq.activo ? tokens.warmCoral : null,
),
tooltip:
estado.ecualizadorActivo
? l10n.equalizerDisable
: l10n.equalizerEnable,
onPressed:
() =>
estado.cambiarEcualizadorActivo(!estado.ecualizadorActivo),
tooltip: eq.activo ? l10n.equalizerDisable : l10n.equalizerEnable,
onPressed: () => eq.cambiarActivo(!eq.activo),
),
IconButton(
icon: Icon(
+80
View File
@@ -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;
}
}
}