fix(alarmas): reload and re-sync alarms after backup import

Importing a backup wrote the alarm/vacation/exception block straight to
SharedPreferences but never told EstadoAlarmas about it, so the UI kept
showing the pre-import alarms, a later edit could persist that stale
state back over the imported one, and imported alarms were never
(re)scheduled with the Android native layer. The backup screen now
calls EstadoAlarmas.cargarPersistidasSinRecalcular() followed by
refrescarProgramacion() after a successful import, extracted into a
directly-testable aplicarImportacionConfig() function.
This commit is contained in:
2026-08-28 22:47:38 +02:00
parent fdddd95199
commit e57f7bb17b
3 changed files with 325 additions and 2 deletions
+6 -1
View File
@@ -945,7 +945,12 @@ class EstadoRadio extends ChangeNotifier {
final alarmasData = data['alarmas'];
if (alarmasData is Map<String, dynamic>) {
// Escribimos el bloque JSON tal como estaba en el dispositivo origen.
// ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso.
// EstadoAlarmas es un ChangeNotifier independiente y de larga vida
// que ya cargó sus alarmas en memoria: NO relee este storage por sí
// solo. El llamador (pantalla_ajustes_backup.dart) es responsable de
// invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido
// de `refrescarProgramacion()` tras un import exitoso; EstadoRadio
// se mantiene deliberadamente sin depender de EstadoAlarmas.
await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData));
}
}
@@ -6,12 +6,45 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../../estado/estado_alarmas.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// Applies a successfully-parsed backup to BOTH independent notifiers that
/// own pieces of it (fix/import-alarmas-y-paywall).
///
/// `EstadoRadio.importarConfig` writes the raw alarm/vacation/exception JSON
/// block straight to SharedPreferences, but `EstadoAlarmas` is a separate
/// long-lived `ChangeNotifier` that loaded its alarms into memory at
/// construction and never re-reads storage on its own — `EstadoRadio` stays
/// deliberately free of a dependency on it. Without the two calls below the
/// imported block is invisible to the running app: the UI keeps showing the
/// pre-import alarms, a later edit would persist that stale in-memory list
/// OVER the imported one, and the imported alarms would never be
/// (re)scheduled with the Android native layer even after a restart.
///
/// Extracted as a top-level function (rather than inlined in `_importar`)
/// so this exact production sequence — not a reimplementation of it — is
/// directly unit-testable without depending on the `file_picker` platform
/// channel or the confirmation dialog.
Future<void> aplicarImportacionConfig(
EstadoRadio estado,
EstadoAlarmas alarmas,
Map<String, dynamic> json,
) async {
await estado.importarConfig(json);
// Re-reads from storage — clears ServicioAlarmas' in-memory cache so the
// just-imported alarms/vacations/exceptions (same JSON block, same
// notifier) replace the stale ones.
await alarmas.cargarPersistidasSinRecalcular();
// Recomputes next-run times against the (now fresh) imported data and
// re-syncs every alarm with the Android native scheduler.
await alarmas.refrescarProgramacion();
}
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
/// only the panel header's icon and title were removed (the pushed screen's
@@ -102,8 +135,9 @@ class _CuerpoBackup extends StatelessWidget {
if (confirmar != true) return;
if (context.mounted) {
final estado = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final messenger = ScaffoldMessenger.of(context);
await estado.importarConfig(json);
await aplicarImportacionConfig(estado, alarmas, json);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupImportSuccess)),
);
+284
View File
@@ -0,0 +1,284 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_backup.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Regression coverage for the data-loss bug (fix/import-alarmas-y-paywall):
/// `EstadoRadio.importarConfig` writes the imported alarm block straight to
/// SharedPreferences, but `EstadoAlarmas` is a separate long-lived
/// `ChangeNotifier` that loaded its alarms into memory at construction and
/// never re-reads on its own. These tests exercise the EXACT sequence the
/// real call site (`pantalla_ajustes_backup.dart`'s `_importar`) now runs
/// after a successful import: `EstadoRadio.importarConfig` followed by
/// `EstadoAlarmas.cargarPersistidasSinRecalcular()` +
/// `EstadoAlarmas.refrescarProgramacion()` — bypassing the file_picker
/// platform channel and the confirmation dialog, which are pure UI
/// plumbing already covered by `pantalla_ajustes_backup_test.dart`.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
SharedPreferences.setMockInitialValues({});
// A PRIVATE per-test file, never the shared `test/fixtures/` one:
// `EstadoRadio.importarConfig` unconditionally calls
// `_guardarEmisorasCustom()`, which WRITES to whatever
// `resolverArchivoCustom` resolves to — pointing that at the shared
// fixture previously clobbered its committed BOM on disk as a side
// effect of running this file's tests.
tempDir = await Directory.systemTemp.createTemp(
'pluriwave_estado_alarmas_import_test',
);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
Future<File> archivoCustomVacio() async {
final file = File('${tempDir.path}/emisoras_custom.json');
if (!file.existsSync()) {
await file.writeAsString('[]');
}
return file;
}
Map<String, dynamic> jsonAlarma(AlarmaMusical a) => {
'id': a.id,
'nombre': a.nombre,
'activa': a.activa,
'hora': a.hora,
'minuto': a.minuto,
'tipoProgramacion': a.tipoProgramacion.name,
'diasSemana': a.diasSemana,
};
const alarmaVieja = AlarmaMusical(
id: 'vieja',
nombre: 'Alarma vieja (pre-import)',
hora: 6,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [1, 2, 3, 4, 5],
);
const alarmaImportada = AlarmaMusical(
id: 'importada',
nombre: 'Alarma importada',
hora: 8,
minuto: 15,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [6, 7],
);
/// Builds the pair the app wires together: `EstadoRadio` (owns
/// `importarConfig`) and `EstadoAlarmas` (owns the alarm reload +
/// re-scheduling this bugfix adds), sharing ONE `SharedPreferences`
/// instance exactly like the real app's provider tree does.
Future<
({
EstadoRadio radio,
EstadoAlarmas alarmas,
FakePuertoAlarmasAndroid android,
})
>
crearPar() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'alarmas_musicales_v1',
jsonEncode({
'alarmas': [jsonAlarma(alarmaVieja)],
'vacaciones': [],
'excepciones': [],
}),
);
final android = FakePuertoAlarmasAndroid();
final alarmas = EstadoAlarmas(
esPremium: () => true,
servicio: ServicioAlarmas(prefs: prefs),
android: android,
prefs: prefs,
iniciarAutomaticamente: false,
);
// Loads + native-syncs the pre-import alarm WITHOUT arming
// `inicializar()`'s periodic timers (irrelevant to this bugfix and a
// needless liability for a `flutter test` run).
await alarmas.refrescarProgramacion();
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: archivoCustomVacio,
prefs: prefs,
iniciarAutomaticamente: false,
);
return (radio: radio, alarmas: alarmas, android: android);
}
Map<String, dynamic> backupCon({
required List<AlarmaMusical> alarmas,
List<Map<String, dynamic>> vacaciones = const [],
List<Map<String, dynamic>> excepciones = const [],
String ordenListas = 'nombre',
}) => {
'version': 2,
'gruposFavoritos': [],
'favoritos': [],
'emisorasCustom': [],
'presetsEcualizador': {},
'alarmas': {
'alarmas': alarmas.map(jsonAlarma).toList(),
'vacaciones': vacaciones,
'excepciones': excepciones,
},
'emisoraPreferidaUuid': null,
'ordenListas': ordenListas,
'timerSuenoPresetsSegundos': <int>[300, 600],
};
test('after import, EstadoAlarmas reflects the imported alarms, not the '
'pre-import ones', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada]),
);
expect(par.alarmas.alarmas.map((a) => a.id), ['importada']);
expect(par.alarmas.alarmas.single.nombre, 'Alarma importada');
});
test('native re-scheduling is triggered after an import', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
// Sanity: the pre-import alarm was already scheduled.
expect(par.android.programadas.map((a) => a.id), contains('vieja'));
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada]),
);
// The imported alarm was handed to the native Android bridge — this is
// what makes it actually ring, not just appear in the list.
expect(par.android.programadas.map((a) => a.id), contains('importada'));
});
test(
'vacation ranges and alarm exceptions in the same block come back too',
() async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
expect(par.alarmas.vacaciones, isEmpty);
expect(par.alarmas.excepciones, isEmpty);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(
alarmas: [alarmaImportada],
vacaciones: [
{
'id': 'vac1',
'nombre': 'Verano',
'inicio': '2026-07-01T00:00:00.000',
'fin': '2026-07-15T00:00:00.000',
'activo': true,
},
],
excepciones: [
{
'alarmaId': 'importada',
'ejecucion': '2026-08-30T08:15:00.000',
'tipo': 'skipNext',
},
],
),
);
expect(par.alarmas.vacaciones.map((v) => v.id), ['vac1']);
expect(par.alarmas.excepciones.map((e) => e.alarmaId), ['importada']);
},
);
test('a failed import (e.g. malformed/unsupported version) leaves existing '
'alarms untouched', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
final backupNoSoportado = backupCon(alarmas: [alarmaImportada])
..['version'] = 99;
// Runs the SAME production function the call site uses: a throw from
// `importarConfig` must propagate before either reload call runs.
await expectLater(
aplicarImportacionConfig(par.radio, par.alarmas, backupNoSoportado),
throwsA(anything),
);
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
expect(par.android.programadas.map((a) => a.id), ['vieja']);
});
test('a cancelled import (dialog declined, importarConfig never called) '
'leaves existing alarms untouched', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
// Simulates the user declining the confirm dialog: the call site
// returns before `importarConfig` and the two reload calls ever run.
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
expect(par.android.programadas.map((a) => a.id), ['vieja']);
});
test('regression: importing still restores preferences (ordenListas) '
'exactly as before', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada], ordenListas: 'nombre'),
);
expect(par.radio.ordenListas.name, 'nombre');
});
}