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
+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(