From 44af98eac3a6e3df02a9f1487a2260ff5f9e6dc4 Mon Sep 17 00:00:00 2001 From: freetlab Date: Tue, 22 Sep 2026 13:12:09 +0200 Subject: [PATCH] fix(alarmas): una alarma de un solo uso a una hora ya pasada suena manana El editor dejaba la fecha clavada en hoy, asi que al escribir 05:30 siendo las 18:30 el candidato quedaba en el pasado, `calcularProxima` lo rechazaba con razon y la alarma se guardaba sin proxima ejecucion: nunca sonaba. En la practica solo se podian poner alarmas unicas para lo que quedaba de dia. `normalizarFechaUnica` resuelve el dia que el usuario realmente quiere, con la convencion de cualquier despertador: hora ya pasada -> manana, hora por llegar -> hoy. Una fecha elegida a proposito en el futuro no se toca nunca, y una fecha rancia salta a hoy/manana en vez de a `fecha + 1`, que seguiria en el pasado. La regla vive en el editor y NO dentro de `calcularProxima`: esa tiene que seguir siendo literal, porque el recalculo posterior al disparo y el motor nativo dependen de que una alarma unica vencida resuelva a null en vez de resucitar al dia siguiente. De paso el editor pasa a leer el reloj inyectado del servicio en vez de `DateTime.now()`, para que la vista previa, los limites del selector de fecha y el ajuste lean el mismo instante y se puedan fijar en las pruebas. --- lib/pantallas/pantalla_alarmas.dart | 51 +++++- .../servicio_programacion_alarmas.dart | 30 ++++ .../pantalla_alarmas_fecha_test.dart | 86 ++++++++++- .../servicio_programacion_alarmas_test.dart | 146 ++++++++++++++---- 4 files changed, 272 insertions(+), 41 deletions(-) diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index da20a24..efd6f6b 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -656,7 +656,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { void initState() { super.initState(); final alarma = widget.alarma; - final ahora = DateTime.now().add(const Duration(minutes: 5)); + final ahora = _ahora().add(const Duration(minutes: 5)); _hora = TimeOfDay( hour: alarma?.hora ?? ahora.hour, minute: alarma?.minuto ?? ahora.minute, @@ -800,7 +800,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { child: Center( child: EditorHoraInline( value: _hora, - onChanged: (nuevo) => setState(() => _hora = nuevo), + onChanged: + (nuevo) => setState(() { + _hora = nuevo; + _reajustarFechaUnica(); + }), ), ), ), @@ -823,7 +827,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { ], selected: {_tipo}, onSelectionChanged: - (value) => setState(() => _tipo = value.first), + (value) => setState(() { + _tipo = value.first; + _reajustarFechaUnica(); + }), ), const SizedBox(height: 10), // Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the @@ -1133,7 +1140,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { ); final proxima = _programacion.calcularProxima( alarma: borrador, - desde: DateTime.now(), + desde: _ahora(), vacaciones: estado.vacaciones, excepciones: estado.excepciones, ); @@ -1163,16 +1170,48 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { } Future _elegirFecha() async { - final ahora = DateTime.now(); + final ahora = _ahora(); final nueva = await showDatePicker( context: context, initialDate: _fecha.isBefore(ahora) ? ahora : _fecha, firstDate: DateTime(ahora.year, ahora.month, ahora.day), lastDate: ahora.add(const Duration(days: 730)), ); - if (nueva != null) setState(() => _fecha = nueva); + if (nueva != null) { + setState(() { + _fecha = nueva; + _reajustarFechaUnica(); + }); + } } + /// Keeps `_fecha` on the day the user actually MEANS for a one-time alarm. + /// Every alarm clock reads "05:30" typed at 18:30 as "05:30 tomorrow"; the + /// editor used to keep the date pinned to today, so `calcularProxima` + /// rightly refused the past candidate and the alarm was saved with no next + /// execution at all — silently never ringing. Runs after every edit that + /// can invalidate the (time, date, recurrence) triple, so the visible date + /// field, the preview line and the saved alarm never disagree. + /// + /// A date the user deliberately picked in the future is left untouched; + /// the rollover rule itself lives in the domain service. + void _reajustarFechaUnica() { + if (_tipo != TipoProgramacionAlarma.unica) return; + _fecha = _programacion.normalizarFechaUnica( + fecha: _fecha, + hora: _hora.hour, + minuto: _hora.minute, + ahora: _ahora(), + ); + } + + /// Single clock for the whole editor. Goes through the alarm service's + /// injected clock instead of `DateTime.now()` so the draft preview, the + /// date picker's bounds and the one-time rollover all read the SAME + /// instant — and so they can be tested at a fixed wall-clock time instead + /// of depending on when the suite happens to run. + DateTime _ahora() => context.read().servicio.ahora(); + Future _guardar() async { if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index 9b7f7cc..436e41b 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -53,6 +53,36 @@ class ServicioProgramacionAlarmas { }; } + /// Resolves the wall-clock date a ONE-TIME alarm should actually land on, + /// given the time the user just typed. Alarm clocks everywhere read "05:30" + /// at 18:30 as "05:30 tomorrow"; without this, `calcularProxima` correctly + /// refuses a candidate that is already in the past and the alarm silently + /// never fires. The rollover belongs here and NOT inside `calcularProxima`: + /// that one must stay literal, because the post-execution path and the + /// native scheduler both rely on a past one-shot resolving to null instead + /// of resurrecting itself on the next day. + /// + /// A date the user picked deliberately in the future is never overridden. + /// A stale date (an old draft reopened days later) snaps to today/tomorrow + /// rather than to `fecha + 1`, which would still be in the past. + /// + /// Day arithmetic goes through the DateTime constructor, never + /// `add(Duration(days: 1))` — see `_siguienteDia` for why. + DateTime normalizarFechaUnica({ + required DateTime fecha, + required int hora, + required int minuto, + required DateTime ahora, + }) { + final elegido = DateTime(fecha.year, fecha.month, fecha.day, hora, minuto); + if (_sigueSiendoInminente(elegido, ahora)) return elegido; + + final hoy = DateTime(ahora.year, ahora.month, ahora.day, hora, minuto); + return _sigueSiendoInminente(hoy, ahora) + ? hoy + : DateTime(ahora.year, ahora.month, ahora.day + 1, hora, minuto); + } + DateTime calcularSnooze(DateTime desde, int minutos) { final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5; return desde.add(Duration(minutes: seguro)); diff --git a/test/pantallas/pantalla_alarmas_fecha_test.dart b/test/pantallas/pantalla_alarmas_fecha_test.dart index 8ac7dfa..061f405 100644 --- a/test/pantallas/pantalla_alarmas_fecha_test.dart +++ b/test/pantallas/pantalla_alarmas_fecha_test.dart @@ -46,7 +46,10 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - Future abrirEditorNuevo(WidgetTester tester) async { + Future abrirEditorNuevo( + WidgetTester tester, { + DateTime Function()? reloj, + }) async { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); @@ -69,7 +72,7 @@ void main() { final android = FakePuertoAlarmasAndroid(); final estadoAlarmas = EstadoAlarmas( esPremium: () => true, - servicio: ServicioAlarmas(reloj: DateTime.now), + servicio: ServicioAlarmas(reloj: reloj ?? DateTime.now), android: android, iniciarAutomaticamente: false, ); @@ -218,5 +221,84 @@ void main() { expect(alarma.sonidoInterno, SonidoInternoAlarma.campanaSuave); }, ); + + // The 18:30 -> 05:30 report: with a real clock the suite could only ever + // assert vague invariants, so the editor reads the alarm service's + // injected clock and these two pin it to a fixed wall-clock instant. + DateTime tardeDelMartes() => DateTime(2026, 9, 22, 18, 30); + + /// Taps the hour segment [veces] times. Tap increments and wraps, so 11 + /// taps move 18h -> 05h. A vertical drag cannot be used here: the sheet + /// scrolls, and the scrollable wins the gesture arena. + Future subirHora(WidgetTester tester, int veces) async { + final hora = find.byKey(const ValueKey('editor-hora-inline-hora')); + await tester.ensureVisible(hora); + for (var i = 0; i < veces; i++) { + await tester.tap(hora); + await tester.pump(); + } + await tester.pumpAndSettle(); + } + + testWidgets('una alarma de un unico uso puesta a una hora ya pasada hoy se ' + 'programa para manana, no se queda sin proxima ejecucion', ( + tester, + ) async { + final estadoAlarmas = await abrirEditorNuevo( + tester, + reloj: tardeDelMartes, + ); + + // Opens at 18:35 (now + 5 min); 11 taps wrap the hour to 05:35, + // which already passed today. + await subirHora(tester, 11); + + await tester.ensureVisible(find.text(l10n.saveAlarmAction)); + await tester.tap(find.text(l10n.saveAlarmAction)); + await tester.pumpAndSettle(); + + final alarma = estadoAlarmas.alarmas.single; + expect(alarma.tipoProgramacion, TipoProgramacionAlarma.unica); + expect(alarma.hora, 5); + expect(alarma.minuto, 35); + expect(alarma.proximaProgramable, DateTime(2026, 9, 23, 5, 35)); + }); + + testWidgets( + 'una hora todavia por llegar sigue sonando hoy mismo, no manana', + (tester) async { + final estadoAlarmas = await abrirEditorNuevo( + tester, + reloj: tardeDelMartes, + ); + + // 18:35 + 3 taps = 21:35, still ahead of 18:30. + await subirHora(tester, 3); + + await tester.ensureVisible(find.text(l10n.saveAlarmAction)); + await tester.tap(find.text(l10n.saveAlarmAction)); + await tester.pumpAndSettle(); + + expect( + estadoAlarmas.alarmas.single.proximaProgramable, + DateTime(2026, 9, 22, 21, 35), + ); + }, + ); + + testWidgets('la vista previa deja de decir "sin proxima ejecucion" ' + 'cuando la hora ya paso hoy', (tester) async { + await abrirEditorNuevo(tester, reloj: tardeDelMartes); + + await subirHora(tester, 11); + + final aviso = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('next-trigger-preview')), + matching: find.byType(Text), + ), + ); + expect(aviso.data, isNot(l10n.alarmNoNextExecution)); + }); }); } diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index 015e4c8..c357d66 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -75,41 +75,38 @@ void main() { expect(proxima, DateTime(2026, 5, 23, 9)); }); - test( - 'un registro de fallo de programacion NO omite esa ejecucion (solo ' - 'skipNext debe hacerlo)', - () { - final alarma = AlarmaMusical( - id: 'a4', - nombre: 'Diaria', - hora: 9, - minuto: 0, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: const [], - ); - final ocurrencia = DateTime(2026, 5, 22, 9); + test('un registro de fallo de programacion NO omite esa ejecucion (solo ' + 'skipNext debe hacerlo)', () { + final alarma = AlarmaMusical( + id: 'a4', + nombre: 'Diaria', + hora: 9, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + final ocurrencia = DateTime(2026, 5, 22, 9); - final proxima = servicio.calcularProxima( - alarma: alarma, - desde: DateTime(2026, 5, 22, 8), - excepciones: [ - ExcepcionAlarma( - alarmaId: 'a4', - ejecucion: ocurrencia, - tipo: ExcepcionAlarma.tipoFalloProgramacion, - ), - ], - ); + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 5, 22, 8), + excepciones: [ + ExcepcionAlarma( + alarmaId: 'a4', + ejecucion: ocurrencia, + tipo: ExcepcionAlarma.tipoFalloProgramacion, + ), + ], + ); - expect( - proxima, - ocurrencia, - reason: - 'un fallo de programacion registrado no debe comportarse ' - 'como un salto de usuario', - ); - }, - ); + expect( + proxima, + ocurrencia, + reason: + 'un fallo de programacion registrado no debe comportarse ' + 'como un salto de usuario', + ); + }); test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () { expect( @@ -354,4 +351,87 @@ void main() { expect(siguiente, isNull); }); }); + + group('ServicioProgramacionAlarmas.normalizarFechaUnica', () { + final servicio = ServicioProgramacionAlarmas(); + + test('rolls to tomorrow when the chosen time already passed today', () { + final fecha = servicio.normalizarFechaUnica( + fecha: DateTime(2026, 9, 22), + hora: 5, + minuto: 30, + ahora: DateTime(2026, 9, 22, 18, 30), + ); + + expect(fecha, DateTime(2026, 9, 23, 5, 30)); + }); + + test('keeps today when the chosen time is still ahead', () { + final fecha = servicio.normalizarFechaUnica( + fecha: DateTime(2026, 9, 22), + hora: 21, + minuto: 45, + ahora: DateTime(2026, 9, 22, 18, 30), + ); + + expect(fecha, DateTime(2026, 9, 22, 21, 45)); + }); + + test('never overrides a date the user deliberately picked ahead', () { + final fecha = servicio.normalizarFechaUnica( + fecha: DateTime(2026, 9, 27), + hora: 5, + minuto: 30, + ahora: DateTime(2026, 9, 22, 18, 30), + ); + + expect(fecha, DateTime(2026, 9, 27, 5, 30)); + }); + + test('snaps a long-stale date to today or tomorrow, not to date + 1', () { + final fecha = servicio.normalizarFechaUnica( + fecha: DateTime(2026, 9, 10), + hora: 5, + minuto: 30, + ahora: DateTime(2026, 9, 22, 18, 30), + ); + + expect(fecha, DateTime(2026, 9, 23, 5, 30)); + }); + + test('stays today inside the imminent-trigger tolerance', () { + final fecha = servicio.normalizarFechaUnica( + fecha: DateTime(2026, 9, 22), + hora: 18, + minuto: 30, + ahora: DateTime(2026, 9, 22, 18, 30, 45), + ); + + expect(fecha, DateTime(2026, 9, 22, 18, 30)); + }); + + test('the rolled date is what calcularProxima can actually schedule', () { + final ahora = DateTime(2026, 9, 22, 18, 30); + final fecha = servicio.normalizarFechaUnica( + fecha: DateTime(2026, 9, 22), + hora: 5, + minuto: 30, + ahora: ahora, + ); + final alarma = AlarmaMusical( + id: 'unica-rodada', + nombre: 'Unica', + hora: 5, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.unica, + diasSemana: const [], + fechaUnica: fecha, + ); + + expect( + servicio.calcularProxima(alarma: alarma, desde: ahora), + DateTime(2026, 9, 23, 5, 30), + ); + }); + }); }