diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index fbce4ea..67fe616 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -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) } - schedulePreNotice(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() } ) } } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 673c437..117c07d 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -116,7 +116,10 @@ class MainActivity : AudioServiceActivity() { fallbackStationName = call.argument("fallbackStationName"), fallbackStationUrl = call.argument("fallbackStationUrl"), fadeInSegundos = call.argument("fadeInSegundos") ?: 0, - preNoticeTemplate = call.argument("preNoticeTemplate") + preNoticeTemplate = call.argument("preNoticeTemplate"), + snoozeCountdownTemplate = call.argument("snoozeCountdownTemplate"), + snoozeAgainLabel = call.argument("snoozeAgainLabel"), + snoozeStopLabel = call.argument("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 diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt index 688d315..d6db767 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -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" diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index abc74b3..56a3d30 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -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 _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 _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 _sincronizarEjecucionesGestionadasPorAndroid() async { try { final ejecuciones = await android.obtenerEjecucionesNativasGestionadas(); diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 4e425b0..5e72261 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -629,6 +629,15 @@ } }, "preNoticeCountdown": "يبدأ خلال {minutes} دقيقة", + "snoozeCountdown": "يرنّ خلال {minutes} دقيقة", + "@snoozeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "snoozeAgainAction": "غفوة مرة أخرى", "@preNoticeCountdown": { "placeholders": { "minutes": { diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 69d2340..d666608 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -629,6 +629,15 @@ } }, "preNoticeCountdown": "{minutes} মিনিটে শুরু হবে", + "snoozeCountdown": "{minutes} মিনিটে বাজবে", + "@snoozeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "snoozeAgainAction": "আবার স্নুজ করুন", "@preNoticeCountdown": { "placeholders": { "minutes": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d14b738..453da3a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 65e46c5..d970916 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c9a16e5..2adba3a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8b739cb..cf1b819 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9147aab..84653c4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -629,6 +629,15 @@ } }, "preNoticeCountdown": "{minutes} मिनट में शुरू होगा", + "snoozeCountdown": "{minutes} मिनट में बजेगा", + "@snoozeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "snoozeAgainAction": "फिर से स्नूज़ करें", "@preNoticeCountdown": { "placeholders": { "minutes": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d5922a6..9eb06fe 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a4cb012..35845a6 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index db62826..dd16fcd 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -629,6 +629,15 @@ } }, "preNoticeCountdown": "{minutes}分後に開始", + "snoozeCountdown": "{minutes}分後に鳴ります", + "@snoozeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "snoozeAgainAction": "もう一度スヌーズ", "@preNoticeCountdown": { "placeholders": { "minutes": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 95120ac..6c778fa 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5755b35..5b96ef9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -629,6 +629,15 @@ } }, "preNoticeCountdown": "Начнётся через {minutes} мин", + "snoozeCountdown": "Прозвонит через {minutes} мин", + "@snoozeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "snoozeAgainAction": "Отложить снова", "@preNoticeCountdown": { "placeholders": { "minutes": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ff419e0..0eaacff 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -629,6 +629,15 @@ } }, "preNoticeCountdown": "{minutes}分钟后开始", + "snoozeCountdown": "{minutes}分钟后响铃", + "@snoozeCountdown": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "snoozeAgainAction": "再次小睡", "@preNoticeCountdown": { "placeholders": { "minutes": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 2c85629..134c043 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index a57ac01..3051bc8 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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 => 'تعديل الجهاز'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 41872da..fc92ca0 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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 => 'ডিভাইস সম্পাদনা করুন'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index af29e52..da699f9 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 31db7d9..e434ddb 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 55f3046..501ceea 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 91aaa20..7a51628 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index b4f58e7..6b1dbc9 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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 => 'डिवाइस संपादित करें'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index b55f965..2a9a3f1 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index adbae0e..ffa22dd 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 19dc35f..f96f012 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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 => 'デバイスを編集'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 26556ec..908e133 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 9ae3e05..b42dee4 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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 => 'Изменить устройство'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index bd718b8..8a06489 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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 => '编辑设备'; diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 8cf1949..6d85836 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -154,10 +154,17 @@ class _PantallaAlarmaSonandoState extends State { Future _detener() async { final radio = context.read(); final alarmas = context.read(); - 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 { Future _posponer(int minutos) async { final radio = context.read(); final alarmas = context.read(); - 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 _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. diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index a95f973..7a9b944 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -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 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 diff --git a/test/estado/estado_alarmas_snooze_test.dart b/test/estado/estado_alarmas_snooze_test.dart index 6b5c962..dde593b 100644 --- a/test/estado/estado_alarmas_snooze_test.dart +++ b/test/estado/estado_alarmas_snooze_test.dart @@ -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(); + 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', + ); + }, + ); }