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
@@ -23,6 +23,10 @@ class EventoAlarmaAndroid {
/// (notification "Posponer" while the app may be backgrounded/killed).
static const accionSnoozed = 'snoozed';
/// Action reported when a pending snooze was cancelled natively from the
/// countdown notification ("Detener" while the app may be killed).
static const accionSnoozeCancelled = 'snoozeCancelled';
final String alarmaId;
final String titulo;
final String accion;
@@ -183,6 +187,16 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
);
}
/// Same sentinel strategy as [_preNoticeTemplate], but for the snooze
/// countdown notification re-posted every minute while an alarm is snoozed.
static String _snoozeCountdownTemplate(AppLocalizations l10n) {
const sentinel = 42424242;
return l10n.snoozeCountdown(sentinel).replaceFirst(
sentinel.toString(),
'{minutes}',
);
}
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@@ -203,6 +217,9 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
'id': alarma.id,
'title': localizedAlarmName(_textos, alarma.nombre),
'preNoticeTemplate': _preNoticeTemplate(_textos),
'snoozeCountdownTemplate': _snoozeCountdownTemplate(_textos),
'snoozeAgainLabel': _textos.snoozeAgainAction,
'snoozeStopLabel': _textos.stopAlarmAction,
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
alarma.snoozeHasta == null