fix(alarm): unfreeze snooze modal and add per-minute snooze countdown

The snooze button was fire-and-forget without error handling: if
posponerAlarma threw (e.g. native scheduleAlarm returns false on a
device without exact-alarm permission), _dismissScreen never ran. The
stuck modal also kept _alarmaSonandoActiva true, which made the next
ring get ignored. _posponer/_detener now dismiss in a finally and stop
audio defensively.

Add a native, AlarmManager-driven snooze countdown notification that
re-posts every minute ("Rings in N min", 3->2->1) while the engine is
dead. scheduleSpec drives scheduleSnoozeCountdown for snoozes (instead
of the 30-min pre-notice). Localized text and button labels travel
Dart->Kotlin as {minutes} templates, same pattern as preNoticeTemplate.

Notification actions: "snooze again" (snoozeAgain, anchored to now)
reports back via the existing snoozed event; "stop" (cancelSnooze)
records a handled occurrence for cold-start reconciliation and emits a
new snoozeCancelled event handled in EstadoAlarmas.

Adds snoozeCountdown/snoozeAgainAction keys across all 13 locales and a
test for the snoozeCancelled event. Kotlin changes are static-reviewed
only; no Android build environment available here.
This commit is contained in:
Javier Bautista Fernández
2026-06-30 15:27:05 +02:00
parent 89ff6a3912
commit 481944815f
34 changed files with 683 additions and 14 deletions
@@ -248,4 +248,61 @@ void main() {
expect(android.programadas.last.snoozeHasta, isNull);
},
);
test(
'evento nativo snoozeCancelled limpia el snooze y avanza sin reprogramar',
() async {
final ahora = DateTime(2026, 6, 11, 7, 32);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
AlarmaMusical(
id: 'cancel1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2026, 6, 11, 7, 30),
snoozeHasta: DateTime(2026, 6, 11, 7, 35),
snoozeOrigen: DateTime(2026, 6, 11, 7, 30),
),
);
expect(estado.alarmas.single.snoozeHasta, isNotNull);
final programadasAntes = android.programadas.length;
final notificado = Completer<void>();
estado.addListener(() {
if (!notificado.isCompleted) notificado.complete();
});
android.emitirEvento(
EventoAlarmaAndroid(
alarmaId: 'cancel1',
titulo: 'Diaria',
accion: EventoAlarmaAndroid.accionSnoozeCancelled,
occurrenceAtMillis:
DateTime(2026, 6, 11, 7, 30).millisecondsSinceEpoch,
),
);
await notificado.future;
expect(estado.alarmas.single.snoozeHasta, isNull);
expect(
estado.alarmas.single.proximaEjecucion,
DateTime(2026, 6, 12, 7, 30),
reason: 'avanza a la próxima ocurrencia diaria',
);
expect(
android.programadas.length,
programadasAntes,
reason: 'el nativo ya reprogramó; no debe haber un segundo programar',
);
},
);
}