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.
This commit is contained in:
@@ -656,7 +656,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final alarma = widget.alarma;
|
final alarma = widget.alarma;
|
||||||
final ahora = DateTime.now().add(const Duration(minutes: 5));
|
final ahora = _ahora().add(const Duration(minutes: 5));
|
||||||
_hora = TimeOfDay(
|
_hora = TimeOfDay(
|
||||||
hour: alarma?.hora ?? ahora.hour,
|
hour: alarma?.hora ?? ahora.hour,
|
||||||
minute: alarma?.minuto ?? ahora.minute,
|
minute: alarma?.minuto ?? ahora.minute,
|
||||||
@@ -800,7 +800,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: EditorHoraInline(
|
child: EditorHoraInline(
|
||||||
value: _hora,
|
value: _hora,
|
||||||
onChanged: (nuevo) => setState(() => _hora = nuevo),
|
onChanged:
|
||||||
|
(nuevo) => setState(() {
|
||||||
|
_hora = nuevo;
|
||||||
|
_reajustarFechaUnica();
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -823,7 +827,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
],
|
],
|
||||||
selected: {_tipo},
|
selected: {_tipo},
|
||||||
onSelectionChanged:
|
onSelectionChanged:
|
||||||
(value) => setState(() => _tipo = value.first),
|
(value) => setState(() {
|
||||||
|
_tipo = value.first;
|
||||||
|
_reajustarFechaUnica();
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
// Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the
|
// Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the
|
||||||
@@ -1133,7 +1140,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
);
|
);
|
||||||
final proxima = _programacion.calcularProxima(
|
final proxima = _programacion.calcularProxima(
|
||||||
alarma: borrador,
|
alarma: borrador,
|
||||||
desde: DateTime.now(),
|
desde: _ahora(),
|
||||||
vacaciones: estado.vacaciones,
|
vacaciones: estado.vacaciones,
|
||||||
excepciones: estado.excepciones,
|
excepciones: estado.excepciones,
|
||||||
);
|
);
|
||||||
@@ -1163,15 +1170,47 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _elegirFecha() async {
|
Future<void> _elegirFecha() async {
|
||||||
final ahora = DateTime.now();
|
final ahora = _ahora();
|
||||||
final nueva = await showDatePicker(
|
final nueva = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: _fecha.isBefore(ahora) ? ahora : _fecha,
|
initialDate: _fecha.isBefore(ahora) ? ahora : _fecha,
|
||||||
firstDate: DateTime(ahora.year, ahora.month, ahora.day),
|
firstDate: DateTime(ahora.year, ahora.month, ahora.day),
|
||||||
lastDate: ahora.add(const Duration(days: 730)),
|
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<EstadoAlarmas>().servicio.ahora();
|
||||||
|
|
||||||
Future<void> _guardar() async {
|
Future<void> _guardar() async {
|
||||||
if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) {
|
if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) {
|
||||||
|
|||||||
@@ -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) {
|
DateTime calcularSnooze(DateTime desde, int minutos) {
|
||||||
final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
|
final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
|
||||||
return desde.add(Duration(minutes: seguro));
|
return desde.add(Duration(minutes: seguro));
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<EstadoAlarmas> abrirEditorNuevo(WidgetTester tester) async {
|
Future<EstadoAlarmas> abrirEditorNuevo(
|
||||||
|
WidgetTester tester, {
|
||||||
|
DateTime Function()? reloj,
|
||||||
|
}) async {
|
||||||
tester.view.physicalSize = const Size(1440, 3200);
|
tester.view.physicalSize = const Size(1440, 3200);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
@@ -69,7 +72,7 @@ void main() {
|
|||||||
final android = FakePuertoAlarmasAndroid();
|
final android = FakePuertoAlarmasAndroid();
|
||||||
final estadoAlarmas = EstadoAlarmas(
|
final estadoAlarmas = EstadoAlarmas(
|
||||||
esPremium: () => true,
|
esPremium: () => true,
|
||||||
servicio: ServicioAlarmas(reloj: DateTime.now),
|
servicio: ServicioAlarmas(reloj: reloj ?? DateTime.now),
|
||||||
android: android,
|
android: android,
|
||||||
iniciarAutomaticamente: false,
|
iniciarAutomaticamente: false,
|
||||||
);
|
);
|
||||||
@@ -218,5 +221,84 @@ void main() {
|
|||||||
expect(alarma.sonidoInterno, SonidoInternoAlarma.campanaSuave);
|
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<void> 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<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('next-trigger-preview')),
|
||||||
|
matching: find.byType(Text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(aviso.data, isNot(l10n.alarmNoNextExecution));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,8 @@ void main() {
|
|||||||
expect(proxima, DateTime(2026, 5, 23, 9));
|
expect(proxima, DateTime(2026, 5, 23, 9));
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('un registro de fallo de programacion NO omite esa ejecucion (solo '
|
||||||
'un registro de fallo de programacion NO omite esa ejecucion (solo '
|
'skipNext debe hacerlo)', () {
|
||||||
'skipNext debe hacerlo)',
|
|
||||||
() {
|
|
||||||
final alarma = AlarmaMusical(
|
final alarma = AlarmaMusical(
|
||||||
id: 'a4',
|
id: 'a4',
|
||||||
nombre: 'Diaria',
|
nombre: 'Diaria',
|
||||||
@@ -108,8 +106,7 @@ void main() {
|
|||||||
'un fallo de programacion registrado no debe comportarse '
|
'un fallo de programacion registrado no debe comportarse '
|
||||||
'como un salto de usuario',
|
'como un salto de usuario',
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () {
|
test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () {
|
||||||
expect(
|
expect(
|
||||||
@@ -354,4 +351,87 @@ void main() {
|
|||||||
expect(siguiente, isNull);
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user