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
+38 -8
View File
@@ -154,10 +154,17 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Future<void> _detener() async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
await _liberarAudioLocal();
await radio.audio.pausar();
await alarmas.finalizarEjecucion(widget.alarma.id);
if (mounted) _dismissScreen();
await _silenciarAudio(radio);
// Dismiss is run from finally so a failing reschedule/teardown can never
// leave the ringing screen stuck open (which would also block the next
// ring via the _alarmaSonandoActiva guard in app.dart).
try {
await alarmas.finalizarEjecucion(widget.alarma.id);
} catch (e) {
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
} finally {
if (mounted) _dismissScreen();
}
}
/// Flutter-first snooze (S2-R1): tears down local audio, then routes
@@ -166,10 +173,33 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Future<void> _posponer(int minutos) async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
await _liberarAudioLocal();
await radio.audio.pausar();
await alarmas.posponerAlarma(widget.alarma, minutos);
if (mounted) _dismissScreen();
await _silenciarAudio(radio);
// See _detener: the screen MUST close even if posponerAlarma throws
// (e.g. native scheduleAlarm returns false on a device without exact
// alarm permission). Otherwise the modal freezes and the snooze never
// re-rings because _alarmaSonandoActiva stays true.
try {
await alarmas.posponerAlarma(widget.alarma, minutos);
} catch (e) {
debugPrint('[PluriWave][alarmas] posponer alarma fallo: $e');
} finally {
if (mounted) _dismissScreen();
}
}
/// Stops both audio sources (local fallback player and the radio handler)
/// without letting either failure abort the caller's dismiss flow.
Future<void> _silenciarAudio(EstadoRadio radio) async {
try {
await _liberarAudioLocal();
} catch (e) {
debugPrint('[PluriWave][alarmas] liberar audio local fallo: $e');
}
try {
await radio.audio.pausar();
} catch (e) {
debugPrint('[PluriWave][alarmas] pausar radio fallo: $e');
}
}
/// Dismisses the alarm screen safely in both live-app and dead-app states.