fix(alarm): recalculate every alarm on each mutation, not just the touched one
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s

guardarAlarma only recomputed proximaEjecucion for the alarm being
saved; every other alarm kept whatever snapshot the last periodic
recalculation left, which can be stale or already past-due. Since
EstadoAlarmas.proximaAlarma just sorts by proximaProgramable, a stale
sibling could wrongly outrank a freshly activated/created/edited
alarm in the "Próxima alarma" panel until the next 1-minute tick.

Extended the same full-list recalculation guardarVacaciones already
did to guardarAlarma, eliminarAlarma, completarEjecucion,
sincronizarEjecucionesNativas, saltarProxima and
posponerEjecucionHasta, via a shared _recalcularLista helper.
This commit is contained in:
Javier Bautista Fernández
2026-07-02 15:21:58 +02:00
parent a8a2db4b64
commit 28b663bbe7
2 changed files with 142 additions and 55 deletions
+68 -55
View File
@@ -116,23 +116,32 @@ class ServicioAlarmas {
}) => _enCola(() async { }) => _enCola(() async {
final config = await _configActual(); final config = await _configActual();
final ahora = _reloj(); final ahora = _reloj();
final vacacionesActuales = vacaciones ?? config.vacaciones;
final excepcionesActuales = excepciones ?? config.excepciones;
final alarmas = List<AlarmaMusical>.from(config.alarmas); final alarmas = List<AlarmaMusical>.from(config.alarmas);
final index = alarmas.indexWhere((a) => a.id == alarma.id); final index = alarmas.indexWhere((a) => a.id == alarma.id);
final normalizada = _recalcular( final actualizada = alarma.copyWith(
alarma.copyWith(creadaEn: alarma.creadaEn ?? ahora, actualizadaEn: ahora), creadaEn: alarma.creadaEn ?? ahora,
vacaciones ?? config.vacaciones, actualizadaEn: ahora,
excepciones ?? config.excepciones,
); );
if (index >= 0) { if (index >= 0) {
alarmas[index] = normalizada; alarmas[index] = actualizada;
} else { } else {
alarmas.add(normalizada); alarmas.add(actualizada);
} }
// Recalculate every alarm, not just the one just saved: siblings can
// hold a proximaEjecucion snapshot from the last periodic tick that is
// stale (even past-due), which would otherwise outrank a freshly
// created/activated/edited alarm when EstadoAlarmas.proximaAlarma sorts
// by proximaProgramable.
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: alarmas, alarmas: _recalcularLista(
vacaciones: vacaciones ?? config.vacaciones, alarmas,
excepciones: excepciones ?? config.excepciones, vacacionesActuales,
excepcionesActuales,
),
vacaciones: vacacionesActuales,
excepciones: excepcionesActuales,
); );
await _guardar(nuevo); await _guardar(nuevo);
return nuevo; return nuevo;
@@ -140,10 +149,13 @@ class ServicioAlarmas {
Future<ConfiguracionAlarmas> eliminarAlarma(String id) => _enCola(() async { Future<ConfiguracionAlarmas> eliminarAlarma(String id) => _enCola(() async {
final config = await _configActual(); final config = await _configActual();
final restantes = config.alarmas.where((a) => a.id != id).toList();
final excepciones =
config.excepciones.where((e) => e.alarmaId != id).toList();
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas.where((a) => a.id != id).toList(), alarmas: _recalcularLista(restantes, config.vacaciones, excepciones),
vacaciones: config.vacaciones, vacaciones: config.vacaciones,
excepciones: config.excepciones.where((e) => e.alarmaId != id).toList(), excepciones: excepciones,
); );
await _guardar(nuevo); await _guardar(nuevo);
return nuevo; return nuevo;
@@ -156,12 +168,12 @@ class ServicioAlarmas {
final normalizadas = final normalizadas =
vacaciones.map((v) => v.normalizado()).toList() vacaciones.map((v) => v.normalizado()).toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
final alarmas =
config.alarmas
.map((a) => _recalcular(a, normalizadas, config.excepciones))
.toList();
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: alarmas, alarmas: _recalcularLista(
config.alarmas,
normalizadas,
config.excepciones,
),
vacaciones: normalizadas, vacaciones: normalizadas,
excepciones: config.excepciones, excepciones: config.excepciones,
); );
@@ -188,10 +200,11 @@ class ServicioAlarmas {
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async { Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
final config = await _configActual(); final config = await _configActual();
final alarmas = final alarmas = _recalcularLista(
config.alarmas config.alarmas,
.map((a) => _recalcular(a, config.vacaciones, config.excepciones)) config.vacaciones,
.toList(); config.excepciones,
);
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: alarmas, alarmas: alarmas,
vacaciones: config.vacaciones, vacaciones: config.vacaciones,
@@ -258,7 +271,7 @@ class ServicioAlarmas {
if (!huboCambios) return config; if (!huboCambios) return config;
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: alarmas, alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones),
vacaciones: config.vacaciones, vacaciones: config.vacaciones,
excepciones: config.excepciones, excepciones: config.excepciones,
); );
@@ -266,38 +279,26 @@ class ServicioAlarmas {
return nuevo; return nuevo;
} }
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) => Future<ConfiguracionAlarmas> saltarProxima(
_enCola(() async { String alarmaId,
final config = await _configActual(); ) => _enCola(() async {
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId); final config = await _configActual();
final proxima = alarma.proximaEjecucion; final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
if (proxima == null) return config; final proxima = alarma.proximaEjecucion;
if (proxima == null) return config;
final excepciones = [ final excepciones = [
...config.excepciones, ...config.excepciones,
ExcepcionAlarma( ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'),
alarmaId: alarmaId, ];
ejecucion: proxima, final nuevo = ConfiguracionAlarmas(
tipo: 'skipNext', alarmas: _recalcularLista(config.alarmas, config.vacaciones, excepciones),
), vacaciones: config.vacaciones,
]; excepciones: excepciones,
final alarmas = );
config.alarmas await _guardar(nuevo);
.map( return nuevo;
(a) => });
a.id == alarmaId
? _recalcular(a, config.vacaciones, excepciones)
: a,
)
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
Future<ConfiguracionAlarmas> posponerEjecucion( Future<ConfiguracionAlarmas> posponerEjecucion(
String alarmaId, String alarmaId,
@@ -340,7 +341,7 @@ class ServicioAlarmas {
) )
.toList(); .toList();
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: alarmas, alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones),
vacaciones: config.vacaciones, vacaciones: config.vacaciones,
excepciones: config.excepciones, excepciones: config.excepciones,
); );
@@ -376,7 +377,7 @@ class ServicioAlarmas {
); );
}).toList(); }).toList();
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: alarmas, alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones),
vacaciones: config.vacaciones, vacaciones: config.vacaciones,
excepciones: config.excepciones, excepciones: config.excepciones,
); );
@@ -459,6 +460,18 @@ class ServicioAlarmas {
); );
} }
// Every mutation that can shift "which alarm fires next" (create, edit,
// activate/deactivate, delete, fire, snooze, skip) recalculates the whole
// list, not just the alarm it touched directly. Siblings otherwise keep a
// proximaEjecucion snapshot from the last periodic tick, which can be
// stale (or already past-due) and wrongly outrank a freshly updated alarm
// wherever proximaProgramable values are compared (EstadoAlarmas.proximaAlarma).
List<AlarmaMusical> _recalcularLista(
List<AlarmaMusical> alarmas,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) => alarmas.map((a) => _recalcular(a, vacaciones, excepciones)).toList();
Future<SharedPreferences> _resolverPrefs() async => Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance(); _prefs ?? SharedPreferences.getInstance();
} }
@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// SharedPreferences spy: only the members ServicioAlarmas touches are
/// implemented; everything else throws via noSuchMethod.
class _PrefsEspia implements SharedPreferences {
final Map<String, Object> _datos = {};
@override
String? getString(String key) => _datos[key] as String?;
@override
Future<bool> setString(String key, String value) async {
_datos[key] = value;
return true;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
// Regression for: activating/creating/deleting/firing an alarm must never
// leave a sibling stuck with a stale (already past-due) proximaEjecucion
// snapshot, since EstadoAlarmas.proximaAlarma just sorts by
// proximaProgramable and a stale past value would incorrectly outrank a
// freshly computed future one.
test(
'guardarAlarma recalcula la proximaEjecucion de TODAS las alarmas, no solo la guardada',
() async {
final prefs = _PrefsEspia();
var reloj = DateTime(2026, 1, 1, 6, 0);
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => reloj);
final a = servicio.crearAlarma(
nombre: 'A',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
final configTrasA = await servicio.guardarAlarma(a);
final aGuardada = configTrasA.alarmas.single;
expect(aGuardada.proximaEjecucion, DateTime(2026, 1, 1, 7, 0));
// Time moves forward well past A's stored occurrence, simulating the
// periodic 1-minute refresh not having ticked yet.
reloj = DateTime(2026, 1, 2, 8, 0);
final b = servicio.crearAlarma(
nombre: 'B',
hora: 9,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
final configTrasB = await servicio.guardarAlarma(b);
final aTrasGuardarB = configTrasB.alarmas.firstWhere(
(alarma) => alarma.id == a.id,
);
expect(
aTrasGuardarB.proximaEjecucion!.isAfter(reloj),
isTrue,
reason:
'A debe recalcularse al guardar B, no quedar con el snapshot '
'viejo (ya vencido) que la haria ganar la comparacion de '
'"proxima alarma" por error',
);
},
);
}