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
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.json.JSONArray
import org.json.JSONObject
@@ -40,7 +43,10 @@ class AlarmScheduler(private val context: Context) {
fallbackStationName: String? = null,
fallbackStationUrl: String? = null,
fadeInSegundos: Int = 0,
preNoticeTemplate: String? = null
preNoticeTemplate: String? = null,
snoozeCountdownTemplate: String? = null,
snoozeAgainLabel: String? = null,
snoozeStopLabel: String? = null
): Boolean {
val existing = readSpec(id)
val preservedSnooze = preserveNativeSnooze(
@@ -72,7 +78,10 @@ class AlarmScheduler(private val context: Context) {
volume = volume.coerceIn(0f, 1f),
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
timezoneId = TimeZone.getDefault().id,
preNoticeTemplate = preNoticeTemplate
preNoticeTemplate = preNoticeTemplate,
snoozeCountdownTemplate = snoozeCountdownTemplate,
snoozeAgainLabel = snoozeAgainLabel,
snoozeStopLabel = snoozeStopLabel
)
return scheduleSpec(spec, persistOnSuccess = true)
}
@@ -118,7 +127,19 @@ class AlarmScheduler(private val context: Context) {
if (persistOnSuccess) {
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)
}
return true
}
@@ -231,6 +252,12 @@ class AlarmScheduler(private val context: Context) {
fun onAlarmFired(id: String) {
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
saveHandledOccurrence(id, firedAt)
val next = spec.copy(
@@ -315,6 +342,227 @@ class AlarmScheduler(private val context: Context) {
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) {
Log.d(tag, "alarm.cancel id=$id")
removeScheduledAlarm(id)
@@ -322,6 +570,7 @@ class AlarmScheduler(private val context: Context) {
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelSnoozeCountdown(id)
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
@@ -659,7 +908,12 @@ class AlarmScheduler(private val context: Context) {
val timezoneId: String,
// Nullable for backward compat: old persisted alarms without this field
// 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 {
put("schemaVersion", 3)
@@ -687,6 +941,9 @@ class AlarmScheduler(private val context: Context) {
put("fadeInSegundos", fadeInSegundos)
put("timezoneId", timezoneId)
put("preNoticeTemplate", preNoticeTemplate)
put("snoozeCountdownTemplate", snoozeCountdownTemplate)
put("snoozeAgainLabel", snoozeAgainLabel)
put("snoozeStopLabel", snoozeStopLabel)
}
companion object {
@@ -724,7 +981,11 @@ class AlarmScheduler(private val context: Context) {
volume = json.optDouble("volume", 0.85).toFloat(),
fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60),
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"),
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
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)
}
@@ -767,6 +770,9 @@ class MainActivity : AudioServiceActivity() {
/** alarmAction reported when the native service snoozed by itself. */
const val ALARM_ACTION_SNOOZED = "snoozed"
/** alarmAction reported when a pending snooze was cancelled natively. */
const val ALARM_ACTION_SNOOZE_CANCELLED = "snoozeCancelled"
@Volatile
private var activeInstance: MainActivity? = null
@@ -91,6 +91,40 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
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")
}
}
@@ -216,6 +250,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
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_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_TITLE = "alarmTitle"
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
/// persists the canonical state — it MUST NOT call android.programar again.
Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async {
if (evento.accion == EventoAlarmaAndroid.accionSnoozeCancelled) {
await _registrarCancelacionSnoozeNativa(evento);
return;
}
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
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 {
try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "يبدأ خلال {minutes} دقيقة",
"snoozeCountdown": "يرنّ خلال {minutes} دقيقة",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "غفوة مرة أخرى",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "{minutes} মিনিটে শুরু হবে",
"snoozeCountdown": "{minutes} মিনিটে বাজবে",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "আবার স্নুজ করুন",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "Startet in {minutes} Min.",
"snoozeCountdown": "Klingelt in {minutes} Min.",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Erneut schlummern",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "Starts in {minutes} min",
"snoozeCountdown": "Rings in {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Snooze again",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -592,6 +592,15 @@
}
},
"preNoticeCountdown": "Empieza en {minutes} min",
"snoozeCountdown": "Suena en {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Posponer otra vez",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "Démarre dans {minutes} min",
"snoozeCountdown": "Sonne dans {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Reporter encore",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "{minutes} मिनट में शुरू होगा",
"snoozeCountdown": "{minutes} मिनट में बजेगा",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "फिर से स्नूज़ करें",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "Mulai dalam {minutes} menit",
"snoozeCountdown": "Berbunyi dalam {minutes} mnt",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Tunda lagi",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "Inizia tra {minutes} min",
"snoozeCountdown": "Suona tra {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Posponi di nuovo",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "{minutes}分後に開始",
"snoozeCountdown": "{minutes}分後に鳴ります",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "もう一度スヌーズ",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "Começa em {minutes} min",
"snoozeCountdown": "Toca em {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Adiar novamente",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "Начнётся через {minutes} мин",
"snoozeCountdown": "Прозвонит через {minutes} мин",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Отложить снова",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+9
View File
@@ -629,6 +629,15 @@
}
},
"preNoticeCountdown": "{minutes}分钟后开始",
"snoozeCountdown": "{minutes}分钟后响铃",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "再次小睡",
"@preNoticeCountdown": {
"placeholders": {
"minutes": {
+12
View File
@@ -2312,6 +2312,18 @@ abstract class AppLocalizations {
/// **'Empieza en {minutes} min'**
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.
///
/// In es, this message translates to:
+8
View File
@@ -1253,6 +1253,14 @@ class AppLocalizationsAr extends AppLocalizations {
return 'يبدأ خلال $minutes دقيقة';
}
@override
String snoozeCountdown(int minutes) {
return 'يرنّ خلال $minutes دقيقة';
}
@override
String get snoozeAgainAction => 'غفوة مرة أخرى';
@override
String get eqDeviceEditTitle => 'تعديل الجهاز';
+8
View File
@@ -1260,6 +1260,14 @@ class AppLocalizationsBn extends AppLocalizations {
return '$minutes মিনিটে শুরু হবে';
}
@override
String snoozeCountdown(int minutes) {
return '$minutes মিনিটে বাজবে';
}
@override
String get snoozeAgainAction => 'আবার স্নুজ করুন';
@override
String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন';
+8
View File
@@ -1270,6 +1270,14 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Startet in $minutes Min.';
}
@override
String snoozeCountdown(int minutes) {
return 'Klingelt in $minutes Min.';
}
@override
String get snoozeAgainAction => 'Erneut schlummern';
@override
String get eqDeviceEditTitle => 'Gerät bearbeiten';
+8
View File
@@ -1256,6 +1256,14 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Starts in $minutes min';
}
@override
String snoozeCountdown(int minutes) {
return 'Rings in $minutes min';
}
@override
String get snoozeAgainAction => 'Snooze again';
@override
String get eqDeviceEditTitle => 'Edit device';
+8
View File
@@ -1265,6 +1265,14 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Empieza en $minutes min';
}
@override
String snoozeCountdown(int minutes) {
return 'Suena en $minutes min';
}
@override
String get snoozeAgainAction => 'Posponer otra vez';
@override
String get eqDeviceEditTitle => 'Editar dispositivo';
+8
View File
@@ -1275,6 +1275,14 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Démarre dans $minutes min';
}
@override
String snoozeCountdown(int minutes) {
return 'Sonne dans $minutes min';
}
@override
String get snoozeAgainAction => 'Reporter encore';
@override
String get eqDeviceEditTitle => 'Modifier l\'appareil';
+8
View File
@@ -1259,6 +1259,14 @@ class AppLocalizationsHi extends AppLocalizations {
return '$minutes मिनट में शुरू होगा';
}
@override
String snoozeCountdown(int minutes) {
return '$minutes मिनट में बजेगा';
}
@override
String get snoozeAgainAction => 'फिर से स्नूज़ करें';
@override
String get eqDeviceEditTitle => 'डिवाइस संपादित करें';
+8
View File
@@ -1264,6 +1264,14 @@ class AppLocalizationsId extends AppLocalizations {
return 'Mulai dalam $minutes menit';
}
@override
String snoozeCountdown(int minutes) {
return 'Berbunyi dalam $minutes mnt';
}
@override
String get snoozeAgainAction => 'Tunda lagi';
@override
String get eqDeviceEditTitle => 'Edit perangkat';
+8
View File
@@ -1270,6 +1270,14 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Inizia tra $minutes min';
}
@override
String snoozeCountdown(int minutes) {
return 'Suona tra $minutes min';
}
@override
String get snoozeAgainAction => 'Posponi di nuovo';
@override
String get eqDeviceEditTitle => 'Modifica dispositivo';
+8
View File
@@ -1220,6 +1220,14 @@ class AppLocalizationsJa extends AppLocalizations {
return '$minutes分後に開始';
}
@override
String snoozeCountdown(int minutes) {
return '$minutes分後に鳴ります';
}
@override
String get snoozeAgainAction => 'もう一度スヌーズ';
@override
String get eqDeviceEditTitle => 'デバイスを編集';
+8
View File
@@ -1262,6 +1262,14 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Começa em $minutes min';
}
@override
String snoozeCountdown(int minutes) {
return 'Toca em $minutes min';
}
@override
String get snoozeAgainAction => 'Adiar novamente';
@override
String get eqDeviceEditTitle => 'Editar dispositivo';
+8
View File
@@ -1266,6 +1266,14 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Начнётся через $minutes мин';
}
@override
String snoozeCountdown(int minutes) {
return 'Прозвонит через $minutes мин';
}
@override
String get snoozeAgainAction => 'Отложить снова';
@override
String get eqDeviceEditTitle => 'Изменить устройство';
+8
View File
@@ -1213,6 +1213,14 @@ class AppLocalizationsZh extends AppLocalizations {
return '$minutes分钟后开始';
}
@override
String snoozeCountdown(int minutes) {
return '$minutes分钟后响铃';
}
@override
String get snoozeAgainAction => '再次小睡';
@override
String get eqDeviceEditTitle => '编辑设备';
+34 -4
View File
@@ -154,11 +154,18 @@ 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 _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
/// through the canonical EstadoAlarmas.posponerAlarma, which hides the
@@ -166,11 +173,34 @@ 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 _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.
///
@@ -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
@@ -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',
);
},
);
}