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
@@ -1,11 +1,14 @@
package es.freetimelab.pluriwave package es.freetimelab.pluriwave
import android.app.AlarmManager import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@@ -40,7 +43,10 @@ class AlarmScheduler(private val context: Context) {
fallbackStationName: String? = null, fallbackStationName: String? = null,
fallbackStationUrl: String? = null, fallbackStationUrl: String? = null,
fadeInSegundos: Int = 0, fadeInSegundos: Int = 0,
preNoticeTemplate: String? = null preNoticeTemplate: String? = null,
snoozeCountdownTemplate: String? = null,
snoozeAgainLabel: String? = null,
snoozeStopLabel: String? = null
): Boolean { ): Boolean {
val existing = readSpec(id) val existing = readSpec(id)
val preservedSnooze = preserveNativeSnooze( val preservedSnooze = preserveNativeSnooze(
@@ -72,7 +78,10 @@ class AlarmScheduler(private val context: Context) {
volume = volume.coerceIn(0f, 1f), volume = volume.coerceIn(0f, 1f),
fadeInSegundos = fadeInSegundos.coerceIn(0, 60), fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
timezoneId = TimeZone.getDefault().id, timezoneId = TimeZone.getDefault().id,
preNoticeTemplate = preNoticeTemplate preNoticeTemplate = preNoticeTemplate,
snoozeCountdownTemplate = snoozeCountdownTemplate,
snoozeAgainLabel = snoozeAgainLabel,
snoozeStopLabel = snoozeStopLabel
) )
return scheduleSpec(spec, persistOnSuccess = true) return scheduleSpec(spec, persistOnSuccess = true)
} }
@@ -118,7 +127,19 @@ class AlarmScheduler(private val context: Context) {
if (persistOnSuccess) { if (persistOnSuccess) {
saveScheduledAlarm(scheduledSpec) saveScheduledAlarm(scheduledSpec)
} }
if (scheduledSpec.snoozeUntilMillis != null) {
// Snoozed: drive the per-minute countdown notification instead of
// the 30-minute pre-notice (which makes no sense for a short snooze).
scheduleSnoozeCountdown(scheduledSpec)
} else {
// Normal occurrence: make sure no stale snooze countdown survives a
// transition from snoozed -> normal, then arm the pre-notice.
cancelSnoozeCountdown(scheduledSpec.id)
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(scheduledSpec.id)
)
schedulePreNotice(scheduledSpec) schedulePreNotice(scheduledSpec)
}
return true return true
} }
@@ -231,6 +252,12 @@ class AlarmScheduler(private val context: Context) {
fun onAlarmFired(id: String) { fun onAlarmFired(id: String) {
val spec = readSpec(id) ?: return val spec = readSpec(id) ?: return
// The alarm is ringing now: tear down any snooze countdown so its
// notification and pending minute ticks don't outlive the ring.
cancelSnoozeCountdown(id)
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
saveHandledOccurrence(id, firedAt) saveHandledOccurrence(id, firedAt)
val next = spec.copy( val next = spec.copy(
@@ -315,6 +342,227 @@ class AlarmScheduler(private val context: Context) {
return occurrenceAt return occurrenceAt
} }
/**
* Re-snoozes an already-snoozed alarm by [minutes] more from NOW (anchored
* to now, unlike [snooze]/[postponeNext] which anchor to the occurrence).
* Invoked from the "snooze again" action on the countdown notification.
* Returns the new snooze so the caller can report it back to Flutter.
*/
fun snoozeAgain(id: String, minutes: Int): NativeSnoozeResult? {
val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val now = System.currentTimeMillis()
val snoozeUntil = now + safeMinutes * 60_000L
Log.d(tag, "alarm.snoozeAgain id=$id minutes=$safeMinutes until=$snoozeUntil")
scheduleSpec(
spec.copy(
snoozeUntilMillis = snoozeUntil,
snoozeOriginMillis = now,
snoozeMinutes = safeMinutes
),
persistOnSuccess = true
)
return NativeSnoozeResult(
snoozeUntilMillis = snoozeUntil,
occurrenceAtMillis = now,
title = spec.title
)
}
/**
* Cancels an active snooze ("stop" from the countdown notification): drops
* the pending ring, advances to the next normal occurrence (or cancels a
* one-shot) and records the occurrence as handled so the Flutter cold-start
* sync reconciles the cleared snooze. Returns the occurrence consumed, or
* null if the spec is gone.
*/
fun cancelSnooze(id: String): Long? {
val spec = readSpec(id) ?: return null
val snoozeUntil = spec.snoozeUntilMillis ?: return null
val occurrence = spec.snoozeOriginMillis ?: spec.triggerAtMillis
// handledAt must be >= the snooze ring time so Flutter's occurrence sync
// does not treat the still-future snooze as pending and advances past it.
val handledAt = maxOf(occurrence, snoozeUntil)
Log.d(tag, "alarm.cancelSnooze id=$id occurrence=$occurrence handledAt=$handledAt")
cancelSnoozeCountdown(id)
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
saveHandledOccurrence(id, handledAt)
val next = spec.copy(
snoozeUntilMillis = null,
snoozeOriginMillis = null,
lastHandledAtMillis = handledAt,
enabled = spec.scheduleType != SCHEDULE_UNICA
)
if (next.enabled) {
scheduleSpec(next, persistOnSuccess = true)
} else {
removeScheduledAlarm(id)
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
}
return occurrence
}
/**
* Re-posts the snooze countdown notification with the up-to-date remaining
* minutes and arms the next minute tick. Driven by ACTION_SNOOZE_COUNTDOWN
* alarms (so it keeps decrementing while the engine is dead).
*/
fun handleSnoozeCountdownTick(id: String) {
val spec = readSpec(id)
val snoozeUntil = spec?.snoozeUntilMillis
if (spec == null || snoozeUntil == null) {
cancelSnoozeCountdown(id)
return
}
val now = System.currentTimeMillis()
if (snoozeUntil <= now) {
// The ring is due; the fire broadcast posts the alarm UI and
// onAlarmFired clears this notification.
cancelSnoozeCountdown(id)
return
}
val remaining = ceilMinutes(snoozeUntil - now)
postSnoozeCountdownNotification(spec, remaining)
armNextSnoozeCountdownTick(spec, remaining)
}
private fun scheduleSnoozeCountdown(spec: NativeAlarmSpec) {
val snoozeUntil = spec.snoozeUntilMillis ?: return
val now = System.currentTimeMillis()
if (snoozeUntil <= now) {
cancelSnoozeCountdown(spec.id)
return
}
val remaining = ceilMinutes(snoozeUntil - now)
postSnoozeCountdownNotification(spec, remaining)
armNextSnoozeCountdownTick(spec, remaining)
Log.d(tag, "alarm.snoozeCountdown started id=${spec.id} remaining=$remaining until=$snoozeUntil")
}
private fun armNextSnoozeCountdownTick(spec: NativeAlarmSpec, remaining: Long) {
val snoozeUntil = spec.snoozeUntilMillis ?: return
// On the final minute the fire alarm takes over and cancels the
// countdown, so there is no further tick to schedule.
if (remaining <= 1L) return
val nextBoundary = snoozeUntil - (remaining - 1L) * 60_000L
val pending = PendingIntent.getBroadcast(
appContext,
requestCode(spec.id, 8),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_SNOOZE_COUNTDOWN
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
nextBoundary,
pending
)
Log.d(tag, "alarm.snoozeCountdown next tick id=${spec.id} at=$nextBoundary")
} catch (error: SecurityException) {
Log.w(tag, "alarm.snoozeCountdown tick SecurityException id=${spec.id}", error)
}
}
private fun cancelSnoozeCountdown(id: String) {
val pending = PendingIntent.getBroadcast(
appContext,
requestCode(id, 8),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_SNOOZE_COUNTDOWN
},
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
cancelPending("snoozeCountdown", pending)
}
private fun postSnoozeCountdownNotification(spec: NativeAlarmSpec, remaining: Long) {
ensurePreNoticeChannel()
val text = if (spec.snoozeCountdownTemplate.isNullOrBlank()) {
"Rings in $remaining min"
} else {
spec.snoozeCountdownTemplate.replace("{minutes}", remaining.toString())
}
val openIntent = PendingIntent.getActivity(
appContext,
requestCode(spec.id, 5),
Intent(appContext, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val againIntent = PendingIntent.getBroadcast(
appContext,
requestCode(spec.id, 6),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_SNOOZE_AGAIN
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = PendingIntent.getBroadcast(
appContext,
requestCode(spec.id, 7),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_CANCEL_SNOOZE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(appContext, PluriWaveAlarmReceiver.CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle(spec.title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setSilent(true)
.setOnlyAlertOnce(true)
.setOngoing(true)
.setContentIntent(openIntent)
.addAction(0, spec.snoozeAgainLabel ?: "Snooze again", againIntent)
.addAction(0, spec.snoozeStopLabel ?: "Stop", stopIntent)
.build()
try {
NotificationManagerCompat.from(appContext).notify(
PluriWaveAlarmReceiver.notificationIdForAlarm(spec.id),
notification
)
} catch (error: SecurityException) {
Log.e(tag, "alarm.snoozeCountdown notify SecurityException id=${spec.id}", error)
}
}
private fun ensurePreNoticeChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(PluriWaveAlarmReceiver.CHANNEL_ID) != null) return
val channel = NotificationChannel(
PluriWaveAlarmReceiver.CHANNEL_ID,
"Preavisos de alarmas",
NotificationManager.IMPORTANCE_LOW
).apply {
setSound(null, null)
enableVibration(false)
}
manager.createNotificationChannel(channel)
}
private fun ceilMinutes(deltaMillis: Long): Long =
maxOf(1L, (deltaMillis + 59_999L) / 60_000L)
fun cancelAlarm(id: String) { fun cancelAlarm(id: String) {
Log.d(tag, "alarm.cancel id=$id") Log.d(tag, "alarm.cancel id=$id")
removeScheduledAlarm(id) removeScheduledAlarm(id)
@@ -322,6 +570,7 @@ class AlarmScheduler(private val context: Context) {
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelSnoozeCountdown(id)
NotificationManagerCompat.from(appContext).cancel( NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id) PluriWaveAlarmReceiver.notificationIdForAlarm(id)
) )
@@ -659,7 +908,12 @@ class AlarmScheduler(private val context: Context) {
val timezoneId: String, val timezoneId: String,
// Nullable for backward compat: old persisted alarms without this field // Nullable for backward compat: old persisted alarms without this field
// fall back to the English default in the receiver. Schema stays v3. // fall back to the English default in the receiver. Schema stays v3.
val preNoticeTemplate: String? = null val preNoticeTemplate: String? = null,
// Localized template + button labels for the per-minute snooze countdown
// notification. Nullable so pre-existing persisted alarms keep working.
val snoozeCountdownTemplate: String? = null,
val snoozeAgainLabel: String? = null,
val snoozeStopLabel: String? = null
) { ) {
fun toJson(): JSONObject = JSONObject().apply { fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", 3) put("schemaVersion", 3)
@@ -687,6 +941,9 @@ class AlarmScheduler(private val context: Context) {
put("fadeInSegundos", fadeInSegundos) put("fadeInSegundos", fadeInSegundos)
put("timezoneId", timezoneId) put("timezoneId", timezoneId)
put("preNoticeTemplate", preNoticeTemplate) put("preNoticeTemplate", preNoticeTemplate)
put("snoozeCountdownTemplate", snoozeCountdownTemplate)
put("snoozeAgainLabel", snoozeAgainLabel)
put("snoozeStopLabel", snoozeStopLabel)
} }
companion object { companion object {
@@ -724,7 +981,11 @@ class AlarmScheduler(private val context: Context) {
volume = json.optDouble("volume", 0.85).toFloat(), volume = json.optDouble("volume", 0.85).toFloat(),
fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60), fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60),
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id), timezoneId = json.optString("timezoneId", TimeZone.getDefault().id),
preNoticeTemplate = json.optString("preNoticeTemplate").takeIf { it.isNotBlank() } preNoticeTemplate = json.optString("preNoticeTemplate").takeIf { it.isNotBlank() },
snoozeCountdownTemplate =
json.optString("snoozeCountdownTemplate").takeIf { it.isNotBlank() },
snoozeAgainLabel = json.optString("snoozeAgainLabel").takeIf { it.isNotBlank() },
snoozeStopLabel = json.optString("snoozeStopLabel").takeIf { it.isNotBlank() }
) )
} }
} }
@@ -116,7 +116,10 @@ class MainActivity : AudioServiceActivity() {
fallbackStationName = call.argument<String>("fallbackStationName"), fallbackStationName = call.argument<String>("fallbackStationName"),
fallbackStationUrl = call.argument<String>("fallbackStationUrl"), fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0, fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0,
preNoticeTemplate = call.argument<String>("preNoticeTemplate") preNoticeTemplate = call.argument<String>("preNoticeTemplate"),
snoozeCountdownTemplate = call.argument<String>("snoozeCountdownTemplate"),
snoozeAgainLabel = call.argument<String>("snoozeAgainLabel"),
snoozeStopLabel = call.argument<String>("snoozeStopLabel")
) )
result.success(scheduled) result.success(scheduled)
} }
@@ -767,6 +770,9 @@ class MainActivity : AudioServiceActivity() {
/** alarmAction reported when the native service snoozed by itself. */ /** alarmAction reported when the native service snoozed by itself. */
const val ALARM_ACTION_SNOOZED = "snoozed" const val ALARM_ACTION_SNOOZED = "snoozed"
/** alarmAction reported when a pending snooze was cancelled natively. */
const val ALARM_ACTION_SNOOZE_CANCELLED = "snoozeCancelled"
@Volatile @Volatile
private var activeInstance: MainActivity? = null private var activeInstance: MainActivity? = null
@@ -91,6 +91,40 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
Log.e(TAG, "alarm.receiver skipNext startActivity ERROR id=$alarmId", error) Log.e(TAG, "alarm.receiver skipNext startActivity ERROR id=$alarmId", error)
} }
} }
ACTION_SNOOZE_COUNTDOWN -> {
AlarmScheduler(context).handleSnoozeCountdownTick(alarmId)
}
ACTION_SNOOZE_AGAIN -> {
val snoozed = AlarmScheduler(context).snoozeAgain(alarmId, snoozeMinutes)
if (snoozed != null) {
// Reuses the existing native-snooze event so Flutter records
// the new snooze (live) and the cold-start sync imports it.
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to alarmId,
"alarmTitle" to snoozed.title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
"snoozeMinutes" to snoozeMinutes
)
)
}
}
ACTION_CANCEL_SNOOZE -> {
val occurrence = AlarmScheduler(context).cancelSnooze(alarmId)
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
if (occurrence != null) {
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to alarmId,
"alarmTitle" to title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZE_CANCELLED,
"occurrenceAtMillis" to occurrence
)
)
}
}
else -> Log.w(TAG, "alarm.receiver unknown action=${intent.action} id=$alarmId") else -> Log.w(TAG, "alarm.receiver unknown action=${intent.action} id=$alarmId")
} }
} }
@@ -216,6 +250,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE" const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT" const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT" const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"
const val ACTION_SNOOZE_COUNTDOWN = "es.freetimelab.pluriwave.alarm.SNOOZE_COUNTDOWN"
const val ACTION_SNOOZE_AGAIN = "es.freetimelab.pluriwave.alarm.SNOOZE_AGAIN"
const val ACTION_CANCEL_SNOOZE = "es.freetimelab.pluriwave.alarm.CANCEL_SNOOZE"
const val EXTRA_ALARM_ID = "alarmId" const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle" const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction" const val EXTRA_ALARM_ACTION = "alarmAction"
+28
View File
@@ -281,6 +281,10 @@ class EstadoAlarmas extends ChangeNotifier {
/// The native scheduler already re-registered setAlarmClock, so this only /// The native scheduler already re-registered setAlarmClock, so this only
/// persists the canonical state — it MUST NOT call android.programar again. /// persists the canonical state — it MUST NOT call android.programar again.
Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async { Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async {
if (evento.accion == EventoAlarmaAndroid.accionSnoozeCancelled) {
await _registrarCancelacionSnoozeNativa(evento);
return;
}
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return; if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return; if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis); final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
@@ -304,6 +308,30 @@ class EstadoAlarmas extends ChangeNotifier {
} }
} }
/// Mirrors a native snooze cancellation ("Detener" on the countdown
/// notification). The native scheduler already advanced to the next normal
/// occurrence, so this only clears the snooze in the canonical config and
/// MUST NOT call android.programar again (would double-schedule).
Future<void> _registrarCancelacionSnoozeNativa(
EventoAlarmaAndroid evento,
) async {
if (evento.alarmaId.isEmpty) return;
final origen =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: DateTime.now();
debugPrint(
'[PluriWave][alarmas] snooze cancelado nativo id=${evento.alarmaId} origen=${origen.toIso8601String()}',
);
try {
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
_aplicar(config);
notifyListeners();
} catch (e) {
debugPrint('[PluriWave][alarmas] cancelar snooze nativo ERROR $e');
}
}
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async { Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
try { try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas(); final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "يبدأ خلال {minutes} دقيقة", "preNoticeCountdown": "يبدأ خلال {minutes} دقيقة",
"snoozeCountdown": "يرنّ خلال {minutes} دقيقة",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "غفوة مرة أخرى",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "{minutes} মিনিটে শুরু হবে", "preNoticeCountdown": "{minutes} মিনিটে শুরু হবে",
"snoozeCountdown": "{minutes} মিনিটে বাজবে",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "আবার স্নুজ করুন",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "Startet in {minutes} Min.", "preNoticeCountdown": "Startet in {minutes} Min.",
"snoozeCountdown": "Klingelt in {minutes} Min.",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Erneut schlummern",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "Starts in {minutes} min", "preNoticeCountdown": "Starts in {minutes} min",
"snoozeCountdown": "Rings in {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Snooze again",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -592,6 +592,15 @@
} }
}, },
"preNoticeCountdown": "Empieza en {minutes} min", "preNoticeCountdown": "Empieza en {minutes} min",
"snoozeCountdown": "Suena en {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Posponer otra vez",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "Démarre dans {minutes} min", "preNoticeCountdown": "Démarre dans {minutes} min",
"snoozeCountdown": "Sonne dans {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Reporter encore",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "{minutes} मिनट में शुरू होगा", "preNoticeCountdown": "{minutes} मिनट में शुरू होगा",
"snoozeCountdown": "{minutes} मिनट में बजेगा",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "फिर से स्नूज़ करें",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "Mulai dalam {minutes} menit", "preNoticeCountdown": "Mulai dalam {minutes} menit",
"snoozeCountdown": "Berbunyi dalam {minutes} mnt",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Tunda lagi",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "Inizia tra {minutes} min", "preNoticeCountdown": "Inizia tra {minutes} min",
"snoozeCountdown": "Suona tra {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Posponi di nuovo",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "{minutes}分後に開始", "preNoticeCountdown": "{minutes}分後に開始",
"snoozeCountdown": "{minutes}分後に鳴ります",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "もう一度スヌーズ",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "Começa em {minutes} min", "preNoticeCountdown": "Começa em {minutes} min",
"snoozeCountdown": "Toca em {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Adiar novamente",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "Начнётся через {minutes} мин", "preNoticeCountdown": "Начнётся через {minutes} мин",
"snoozeCountdown": "Прозвонит через {minutes} мин",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Отложить снова",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+9
View File
@@ -629,6 +629,15 @@
} }
}, },
"preNoticeCountdown": "{minutes}分钟后开始", "preNoticeCountdown": "{minutes}分钟后开始",
"snoozeCountdown": "{minutes}分钟后响铃",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "再次小睡",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+12
View File
@@ -2312,6 +2312,18 @@ abstract class AppLocalizations {
/// **'Empieza en {minutes} min'** /// **'Empieza en {minutes} min'**
String preNoticeCountdown(int minutes); String preNoticeCountdown(int minutes);
/// No description provided for @snoozeCountdown.
///
/// In es, this message translates to:
/// **'Suena en {minutes} min'**
String snoozeCountdown(int minutes);
/// No description provided for @snoozeAgainAction.
///
/// In es, this message translates to:
/// **'Posponer otra vez'**
String get snoozeAgainAction;
/// No description provided for @eqDeviceEditTitle. /// No description provided for @eqDeviceEditTitle.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
+8
View File
@@ -1253,6 +1253,14 @@ class AppLocalizationsAr extends AppLocalizations {
return 'يبدأ خلال $minutes دقيقة'; return 'يبدأ خلال $minutes دقيقة';
} }
@override
String snoozeCountdown(int minutes) {
return 'يرنّ خلال $minutes دقيقة';
}
@override
String get snoozeAgainAction => 'غفوة مرة أخرى';
@override @override
String get eqDeviceEditTitle => 'تعديل الجهاز'; String get eqDeviceEditTitle => 'تعديل الجهاز';
+8
View File
@@ -1260,6 +1260,14 @@ class AppLocalizationsBn extends AppLocalizations {
return '$minutes মিনিটে শুরু হবে'; return '$minutes মিনিটে শুরু হবে';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes মিনিটে বাজবে';
}
@override
String get snoozeAgainAction => 'আবার স্নুজ করুন';
@override @override
String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন'; String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন';
+8
View File
@@ -1270,6 +1270,14 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Startet in $minutes Min.'; return 'Startet in $minutes Min.';
} }
@override
String snoozeCountdown(int minutes) {
return 'Klingelt in $minutes Min.';
}
@override
String get snoozeAgainAction => 'Erneut schlummern';
@override @override
String get eqDeviceEditTitle => 'Gerät bearbeiten'; String get eqDeviceEditTitle => 'Gerät bearbeiten';
+8
View File
@@ -1256,6 +1256,14 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Starts in $minutes min'; return 'Starts in $minutes min';
} }
@override
String snoozeCountdown(int minutes) {
return 'Rings in $minutes min';
}
@override
String get snoozeAgainAction => 'Snooze again';
@override @override
String get eqDeviceEditTitle => 'Edit device'; String get eqDeviceEditTitle => 'Edit device';
+8
View File
@@ -1265,6 +1265,14 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Empieza en $minutes min'; return 'Empieza en $minutes min';
} }
@override
String snoozeCountdown(int minutes) {
return 'Suena en $minutes min';
}
@override
String get snoozeAgainAction => 'Posponer otra vez';
@override @override
String get eqDeviceEditTitle => 'Editar dispositivo'; String get eqDeviceEditTitle => 'Editar dispositivo';
+8
View File
@@ -1275,6 +1275,14 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Démarre dans $minutes min'; return 'Démarre dans $minutes min';
} }
@override
String snoozeCountdown(int minutes) {
return 'Sonne dans $minutes min';
}
@override
String get snoozeAgainAction => 'Reporter encore';
@override @override
String get eqDeviceEditTitle => 'Modifier l\'appareil'; String get eqDeviceEditTitle => 'Modifier l\'appareil';
+8
View File
@@ -1259,6 +1259,14 @@ class AppLocalizationsHi extends AppLocalizations {
return '$minutes मिनट में शुरू होगा'; return '$minutes मिनट में शुरू होगा';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes मिनट में बजेगा';
}
@override
String get snoozeAgainAction => 'फिर से स्नूज़ करें';
@override @override
String get eqDeviceEditTitle => 'डिवाइस संपादित करें'; String get eqDeviceEditTitle => 'डिवाइस संपादित करें';
+8
View File
@@ -1264,6 +1264,14 @@ class AppLocalizationsId extends AppLocalizations {
return 'Mulai dalam $minutes menit'; return 'Mulai dalam $minutes menit';
} }
@override
String snoozeCountdown(int minutes) {
return 'Berbunyi dalam $minutes mnt';
}
@override
String get snoozeAgainAction => 'Tunda lagi';
@override @override
String get eqDeviceEditTitle => 'Edit perangkat'; String get eqDeviceEditTitle => 'Edit perangkat';
+8
View File
@@ -1270,6 +1270,14 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Inizia tra $minutes min'; return 'Inizia tra $minutes min';
} }
@override
String snoozeCountdown(int minutes) {
return 'Suona tra $minutes min';
}
@override
String get snoozeAgainAction => 'Posponi di nuovo';
@override @override
String get eqDeviceEditTitle => 'Modifica dispositivo'; String get eqDeviceEditTitle => 'Modifica dispositivo';
+8
View File
@@ -1220,6 +1220,14 @@ class AppLocalizationsJa extends AppLocalizations {
return '$minutes分後に開始'; return '$minutes分後に開始';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes分後に鳴ります';
}
@override
String get snoozeAgainAction => 'もう一度スヌーズ';
@override @override
String get eqDeviceEditTitle => 'デバイスを編集'; String get eqDeviceEditTitle => 'デバイスを編集';
+8
View File
@@ -1262,6 +1262,14 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Começa em $minutes min'; return 'Começa em $minutes min';
} }
@override
String snoozeCountdown(int minutes) {
return 'Toca em $minutes min';
}
@override
String get snoozeAgainAction => 'Adiar novamente';
@override @override
String get eqDeviceEditTitle => 'Editar dispositivo'; String get eqDeviceEditTitle => 'Editar dispositivo';
+8
View File
@@ -1266,6 +1266,14 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Начнётся через $minutes мин'; return 'Начнётся через $minutes мин';
} }
@override
String snoozeCountdown(int minutes) {
return 'Прозвонит через $minutes мин';
}
@override
String get snoozeAgainAction => 'Отложить снова';
@override @override
String get eqDeviceEditTitle => 'Изменить устройство'; String get eqDeviceEditTitle => 'Изменить устройство';
+8
View File
@@ -1213,6 +1213,14 @@ class AppLocalizationsZh extends AppLocalizations {
return '$minutes分钟后开始'; return '$minutes分钟后开始';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes分钟后响铃';
}
@override
String get snoozeAgainAction => '再次小睡';
@override @override
String get eqDeviceEditTitle => '编辑设备'; String get eqDeviceEditTitle => '编辑设备';
+34 -4
View File
@@ -154,11 +154,18 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Future<void> _detener() async { Future<void> _detener() async {
final radio = context.read<EstadoRadio>(); final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>(); final alarmas = context.read<EstadoAlarmas>();
await _liberarAudioLocal(); await _silenciarAudio(radio);
await radio.audio.pausar(); // 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); await alarmas.finalizarEjecucion(widget.alarma.id);
} catch (e) {
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
} finally {
if (mounted) _dismissScreen(); if (mounted) _dismissScreen();
} }
}
/// Flutter-first snooze (S2-R1): tears down local audio, then routes /// Flutter-first snooze (S2-R1): tears down local audio, then routes
/// through the canonical EstadoAlarmas.posponerAlarma, which hides the /// through the canonical EstadoAlarmas.posponerAlarma, which hides the
@@ -166,11 +173,34 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Future<void> _posponer(int minutos) async { Future<void> _posponer(int minutos) async {
final radio = context.read<EstadoRadio>(); final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>(); final alarmas = context.read<EstadoAlarmas>();
await _liberarAudioLocal(); await _silenciarAudio(radio);
await radio.audio.pausar(); // 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); await alarmas.posponerAlarma(widget.alarma, minutos);
} catch (e) {
debugPrint('[PluriWave][alarmas] posponer alarma fallo: $e');
} finally {
if (mounted) _dismissScreen(); 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. /// Dismisses the alarm screen safely in both live-app and dead-app states.
/// ///
@@ -23,6 +23,10 @@ class EventoAlarmaAndroid {
/// (notification "Posponer" while the app may be backgrounded/killed). /// (notification "Posponer" while the app may be backgrounded/killed).
static const accionSnoozed = 'snoozed'; 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 alarmaId;
final String titulo; final String titulo;
final String accion; 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 @override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream; Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@@ -203,6 +217,9 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
'id': alarma.id, 'id': alarma.id,
'title': localizedAlarmName(_textos, alarma.nombre), 'title': localizedAlarmName(_textos, alarma.nombre),
'preNoticeTemplate': _preNoticeTemplate(_textos), 'preNoticeTemplate': _preNoticeTemplate(_textos),
'snoozeCountdownTemplate': _snoozeCountdownTemplate(_textos),
'snoozeAgainLabel': _textos.snoozeAgainAction,
'snoozeStopLabel': _textos.stopAlarmAction,
'triggerAtMillis': proxima.millisecondsSinceEpoch, 'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis': 'preNoticeAtMillis':
alarma.snoozeHasta == null alarma.snoozeHasta == null
@@ -248,4 +248,61 @@ void main() {
expect(android.programadas.last.snoozeHasta, isNull); 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',
);
},
);
} }