2 Commits
Author SHA1 Message Date
Javier Bautista Fernández ffd09a2179 i18n(alarm): localize all native notification, channel and chooser texts
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
Centralize every native-side user-facing string in a single
AlarmNotificationStrings store written by Flutter via a new
setNotificationStrings MethodChannel whenever the app locale changes,
and read at notification/channel build time (with English fallbacks)
even when the engine is dead. This replaces the hardcoded Spanish text
in the ringing notification ("Alarma PluriWave", "Posponer", "Detener"),
the pre-notice notification ("Posponer", "Omitir esta vez"), both
notification channels (names + descriptions) and the file-action
choosers ("Abrir carpeta", "Abrir grabación").

The per-alarm preNoticeTemplate/snoozeCountdown template+label args are
dropped from scheduleAlarm and the persisted spec and folded into the
shared store, so a locale change now also relocalizes already-scheduled
alarms. Channels are re-created on each use so their name/description
refresh after a language switch.

Adds alarmRingingNotificationTitle, alarmFire/PreNoticeChannelName,
alarmFire/PreNoticeChannelDescription and openFolder/openRecording
chooser keys across all 13 locales (reusing snoozeAction, stopAlarmAction,
skipNextAction, snoozeAgainAction). Rewrites the template test around
setNotificationStrings. Kotlin is static-reviewed only; no Android build
environment available here.
2026-06-30 15:41:29 +02:00
Javier Bautista Fernández 481944815f 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.
2026-06-30 15:27:05 +02:00
37 changed files with 1276 additions and 112 deletions
@@ -0,0 +1,72 @@
package es.freetimelab.pluriwave
import android.content.Context
/**
* Localized strings for native alarm notifications, channels and choosers.
*
* Flutter is the single source of truth for i18n: it pushes the current-locale
* strings via the `setNotificationStrings` MethodChannel whenever the app locale
* is (re)configured. They are persisted in device-protected storage so the
* native side can read them when building a notification or channel even while
* the Flutter engine is dead (alarm fired from a killed app, after reboot, in
* direct-boot). Every getter falls back to English when a value is unset.
*/
object AlarmNotificationStrings {
private const val PREFS = "pluriwave_alarm_strings"
const val KEY_RING_TITLE = "ringTitle"
const val KEY_SNOOZE = "snoozeLabel"
const val KEY_STOP = "stopLabel"
const val KEY_SKIP = "skipLabel"
const val KEY_SNOOZE_AGAIN = "snoozeAgainLabel"
const val KEY_FIRE_CHANNEL_NAME = "fireChannelName"
const val KEY_FIRE_CHANNEL_DESC = "fireChannelDescription"
const val KEY_PRE_NOTICE_CHANNEL_NAME = "preNoticeChannelName"
const val KEY_PRE_NOTICE_CHANNEL_DESC = "preNoticeChannelDescription"
const val KEY_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
const val KEY_OPEN_FOLDER = "openFolderTitle"
const val KEY_OPEN_RECORDING = "openRecordingTitle"
/** Persists the localized strings pushed by Flutter. Blank values are removed. */
fun save(context: Context, values: Map<String, Any?>) {
val editor = prefs(context).edit()
for ((key, value) in values) {
val str = value as? String
if (str.isNullOrBlank()) editor.remove(key) else editor.putString(key, str)
}
editor.apply()
}
fun ringTitle(context: Context) = get(context, KEY_RING_TITLE, "PluriWave alarm")
fun snoozeLabel(context: Context) = get(context, KEY_SNOOZE, "Snooze")
fun stopLabel(context: Context) = get(context, KEY_STOP, "Stop")
fun skipLabel(context: Context) = get(context, KEY_SKIP, "Skip this time")
fun snoozeAgainLabel(context: Context) = get(context, KEY_SNOOZE_AGAIN, "Snooze again")
fun fireChannelName(context: Context) = get(context, KEY_FIRE_CHANNEL_NAME, "Ringing alarms")
fun fireChannelDescription(context: Context) =
get(context, KEY_FIRE_CHANNEL_DESC, "Urgent sound and screen when a music alarm must ring")
fun preNoticeChannelName(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_NAME, "Alarm reminders")
fun preNoticeChannelDescription(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
fun preNoticeText(context: Context, minutes: Long): String =
format(get(context, KEY_PRE_NOTICE_TEMPLATE, "Starts in {minutes} min"), minutes)
fun snoozeCountdownText(context: Context, minutes: Long): String =
format(get(context, KEY_SNOOZE_COUNTDOWN_TEMPLATE, "Rings in {minutes} min"), minutes)
private fun format(template: String, minutes: Long): String =
template.replace("{minutes}", minutes.toString())
private fun get(context: Context, key: String, fallback: String): String =
prefs(context).getString(key, null)?.takeIf { it.isNotBlank() } ?: fallback
private fun prefs(context: Context) =
context.applicationContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -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
@@ -39,8 +42,7 @@ class AlarmScheduler(private val context: Context) {
snoozeMinutes: Int = 5, snoozeMinutes: Int = 5,
fallbackStationName: String? = null, fallbackStationName: String? = null,
fallbackStationUrl: String? = null, fallbackStationUrl: String? = null,
fadeInSegundos: Int = 0, fadeInSegundos: Int = 0
preNoticeTemplate: String? = null
): Boolean { ): Boolean {
val existing = readSpec(id) val existing = readSpec(id)
val preservedSnooze = preserveNativeSnooze( val preservedSnooze = preserveNativeSnooze(
@@ -71,8 +73,7 @@ class AlarmScheduler(private val context: Context) {
fallbackSound = fallbackSound, fallbackSound = fallbackSound,
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
) )
return scheduleSpec(spec, persistOnSuccess = true) return scheduleSpec(spec, persistOnSuccess = true)
} }
@@ -118,7 +119,19 @@ class AlarmScheduler(private val context: Context) {
if (persistOnSuccess) { if (persistOnSuccess) {
saveScheduledAlarm(scheduledSpec) 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 return true
} }
@@ -147,7 +160,6 @@ class AlarmScheduler(private val context: Context) {
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis spec.snoozeOriginMillis ?: spec.triggerAtMillis
) )
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
@@ -168,7 +180,6 @@ class AlarmScheduler(private val context: Context) {
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis spec.snoozeOriginMillis ?: spec.triggerAtMillis
) )
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
} }
) )
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}") Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
@@ -231,6 +242,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 +332,225 @@ 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 = AlarmNotificationStrings.snoozeCountdownText(appContext, remaining)
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, AlarmNotificationStrings.snoozeAgainLabel(appContext), againIntent)
.addAction(0, AlarmNotificationStrings.stopLabel(appContext), 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
// Re-create each time so the localized name/description refresh after a
// locale change (Android updates them on an existing channel).
val channel = NotificationChannel(
PluriWaveAlarmReceiver.CHANNEL_ID,
AlarmNotificationStrings.preNoticeChannelName(appContext),
NotificationManager.IMPORTANCE_LOW
).apply {
description = AlarmNotificationStrings.preNoticeChannelDescription(appContext)
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 +558,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)
) )
@@ -656,10 +893,7 @@ class AlarmScheduler(private val context: Context) {
val fallbackSound: String?, val fallbackSound: String?,
val volume: Float, val volume: Float,
val fadeInSegundos: Int = 0, val fadeInSegundos: Int = 0,
val timezoneId: String, 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
) { ) {
fun toJson(): JSONObject = JSONObject().apply { fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", 3) put("schemaVersion", 3)
@@ -686,7 +920,6 @@ class AlarmScheduler(private val context: Context) {
put("volume", volume) put("volume", volume)
put("fadeInSegundos", fadeInSegundos) put("fadeInSegundos", fadeInSegundos)
put("timezoneId", timezoneId) put("timezoneId", timezoneId)
put("preNoticeTemplate", preNoticeTemplate)
} }
companion object { companion object {
@@ -723,8 +956,7 @@ class AlarmScheduler(private val context: Context) {
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() }, fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
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() }
) )
} }
} }
@@ -739,9 +971,6 @@ class AlarmScheduler(private val context: Context) {
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
private const val SCHEDULE_UNICA = "unica" private const val SCHEDULE_UNICA = "unica"
private const val SCHEDULE_DIAS_SEMANA = "diasSemana" private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
// Intent extra key for the localized pre-notice template string.
// Declared once here; PluriWaveAlarmReceiver reads it via this constant.
const val EXTRA_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
} }
} }
@@ -115,8 +115,7 @@ class MainActivity : AudioServiceActivity() {
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5, snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
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")
) )
result.success(scheduled) result.success(scheduled)
} }
@@ -206,6 +205,16 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel getNativeSnoozeState") Log.d(tag, "alarm.channel getNativeSnoozeState")
result.success(alarmScheduler.nativeSnoozeStates()) result.success(alarmScheduler.nativeSnoozeStates())
} }
"setNotificationStrings" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
AlarmNotificationStrings.save(
this,
args.entries.associate { (k, v) -> k.toString() to v }
)
}
result.success(null)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -424,7 +433,9 @@ class MainActivity : AudioServiceActivity() {
for (intent in candidates) { for (intent in candidates) {
try { try {
startActivity(Intent.createChooser(intent, "Abrir carpeta")) startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openFolderTitle(this))
)
Log.d(tag, "file_actions.viewDirectory launched path=$path") Log.d(tag, "file_actions.viewDirectory launched path=$path")
return true return true
} catch (_: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {
@@ -468,7 +479,9 @@ class MainActivity : AudioServiceActivity() {
clipData = ClipData.newUri(contentResolver, "recording", uri) clipData = ClipData.newUri(contentResolver, "recording", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
startActivity(Intent.createChooser(intent, "Abrir grabación")) startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openRecordingTitle(this))
)
Log.d(tag, "file_actions.openFile launched path=$path") Log.d(tag, "file_actions.openFile launched path=$path")
true true
} catch (_: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {
@@ -767,6 +780,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
@@ -51,8 +51,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
title, title,
snoozeMinutes, snoozeMinutes,
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L), intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L), intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
intent.getStringExtra(AlarmScheduler.EXTRA_PRE_NOTICE_TEMPLATE)
) )
} }
ACTION_POSTPONE_NEXT -> { ACTION_POSTPONE_NEXT -> {
@@ -91,6 +90,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")
} }
} }
@@ -101,13 +134,12 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
title: String, title: String,
snoozeMinutes: Int, snoozeMinutes: Int,
triggerAtMillis: Long, triggerAtMillis: Long,
occurrenceAtMillis: Long, occurrenceAtMillis: Long
preNoticeTemplate: String? = null
) { ) {
ensureChannel(context) ensureChannel(context)
val remaining = computeRemainingMinutes(triggerAtMillis) val remaining = computeRemainingMinutes(triggerAtMillis)
val contentText = formatPreNoticeText(preNoticeTemplate, remaining) val contentText = AlarmNotificationStrings.preNoticeText(context, remaining)
val openAppIntent = PendingIntent.getActivity( val openAppIntent = PendingIntent.getActivity(
context, context,
@@ -155,8 +187,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
.setSilent(true) .setSilent(true)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(openAppIntent) .setContentIntent(openAppIntent)
.addAction(0, "Posponer", postponeNextIntent) .addAction(0, AlarmNotificationStrings.snoozeLabel(context), postponeNextIntent)
.addAction(0, "Omitir esta vez", skipNextIntent) .addAction(0, AlarmNotificationStrings.skipLabel(context), skipNextIntent)
.build() .build()
try { try {
@@ -174,30 +206,17 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
private fun computeRemainingMinutes(triggerAtMillis: Long): Long = private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
maxOf(1L, (triggerAtMillis - System.currentTimeMillis()) / 60_000L) maxOf(1L, (triggerAtMillis - System.currentTimeMillis()) / 60_000L)
/**
* Formats the pre-notice notification text by replacing the `{minutes}`
* placeholder in [template] with [remaining]. Falls back to an English
* default if [template] is null or blank.
*/
private fun formatPreNoticeText(template: String?, remaining: Long): String {
if (template.isNullOrBlank()) {
return "Starts in $remaining min"
}
return template.replace("{minutes}", remaining.toString())
}
private fun ensureChannel(context: Context) { private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID) // Re-create each time so the localized name/description refresh after a
if (existing != null) return // locale change (Android updates them on an existing channel).
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Preavisos de alarmas", AlarmNotificationStrings.preNoticeChannelName(context),
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "Notificaciones silenciosas 30 minutos antes de la alarma" description = AlarmNotificationStrings.preNoticeChannelDescription(context)
setSound(null, null) setSound(null, null)
enableVibration(false) enableVibration(false)
} }
@@ -216,6 +235,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"
@@ -388,7 +388,7 @@ class PluriWaveAlarmService : Service() {
) = ) =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm) .setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave") .setContentTitle(AlarmNotificationStrings.ringTitle(this))
.setContentText( .setContentText(
if (stationName.isNullOrBlank()) title else "$title - $stationName" if (stationName.isNullOrBlank()) title else "$title - $stationName"
) )
@@ -399,8 +399,8 @@ class PluriWaveAlarmService : Service() {
.setAutoCancel(false) .setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true) .setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes)) .setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
.addAction(0, "Posponer", snoozePendingIntent(alarmId, snoozeMinutes)) .addAction(0, AlarmNotificationStrings.snoozeLabel(this), snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(alarmId)) .addAction(0, AlarmNotificationStrings.stopLabel(this), stopPendingIntent(alarmId))
.build() .build()
private fun openAlarmPendingIntent( private fun openAlarmPendingIntent(
@@ -553,13 +553,16 @@ class PluriWaveAlarmService : Service() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
migrateLegacyChannels(context, manager) migrateLegacyChannels(context, manager)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return // Re-create each time (not early-returning when present) so the
// localized name/description refresh after a locale change. Android
// updates name + description on an existing channel; importance and
// sound stay fixed from first creation.
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Alarmas sonando", AlarmNotificationStrings.fireChannelName(context),
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_HIGH
).apply { ).apply {
description = "Sonido y pantalla urgente cuando una alarma musical debe sonar" description = AlarmNotificationStrings.fireChannelDescription(context)
enableVibration(true) enableVibration(true)
setSound( setSound(
Settings.System.DEFAULT_ALARM_ALERT_URI, Settings.System.DEFAULT_ALARM_ALERT_URI,
+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();
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "يبدأ خلال {minutes} دقيقة", "preNoticeCountdown": "يبدأ خلال {minutes} دقيقة",
"snoozeCountdown": "يرنّ خلال {minutes} دقيقة",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "غفوة مرة أخرى",
"alarmRingingNotificationTitle": "منبّه PluriWave",
"alarmFireChannelName": "المنبّهات الرنّانة",
"alarmFireChannelDescription": "صوت وشاشة عاجلة عندما يجب أن يرنّ منبّه موسيقي",
"alarmPreNoticeChannelName": "تنبيهات المنبّهات",
"alarmPreNoticeChannelDescription": "إشعارات صامتة قبل المنبّه",
"openFolderChooserTitle": "فتح المجلد",
"openRecordingChooserTitle": "فتح التسجيل",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "{minutes} মিনিটে শুরু হবে", "preNoticeCountdown": "{minutes} মিনিটে শুরু হবে",
"snoozeCountdown": "{minutes} মিনিটে বাজবে",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "আবার স্নুজ করুন",
"alarmRingingNotificationTitle": "PluriWave অ্যালার্ম",
"alarmFireChannelName": "বাজতে থাকা অ্যালার্ম",
"alarmFireChannelDescription": "কোনো মিউজিক অ্যালার্ম বাজার সময় জরুরি শব্দ ও স্ক্রিন",
"alarmPreNoticeChannelName": "অ্যালার্ম রিমাইন্ডার",
"alarmPreNoticeChannelDescription": "অ্যালার্মের আগে নীরব বিজ্ঞপ্তি",
"openFolderChooserTitle": "ফোল্ডার খুলুন",
"openRecordingChooserTitle": "রেকর্ডিং খুলুন",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "Startet in {minutes} Min.", "preNoticeCountdown": "Startet in {minutes} Min.",
"snoozeCountdown": "Klingelt in {minutes} Min.",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Erneut schlummern",
"alarmRingingNotificationTitle": "PluriWave-Wecker",
"alarmFireChannelName": "Klingelnde Wecker",
"alarmFireChannelDescription": "Dringender Ton und Bildschirm, wenn ein Musikwecker klingeln soll",
"alarmPreNoticeChannelName": "Wecker-Vorankündigungen",
"alarmPreNoticeChannelDescription": "Lautlose Benachrichtigungen vor dem Wecker",
"openFolderChooserTitle": "Ordner öffnen",
"openRecordingChooserTitle": "Aufnahme öffnen",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "Starts in {minutes} min", "preNoticeCountdown": "Starts in {minutes} min",
"snoozeCountdown": "Rings in {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Snooze again",
"alarmRingingNotificationTitle": "PluriWave alarm",
"alarmFireChannelName": "Ringing alarms",
"alarmFireChannelDescription": "Urgent sound and screen when a music alarm must ring",
"alarmPreNoticeChannelName": "Alarm reminders",
"alarmPreNoticeChannelDescription": "Silent notifications before the alarm",
"openFolderChooserTitle": "Open folder",
"openRecordingChooserTitle": "Open recording",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -592,6 +592,22 @@
} }
}, },
"preNoticeCountdown": "Empieza en {minutes} min", "preNoticeCountdown": "Empieza en {minutes} min",
"snoozeCountdown": "Suena en {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Posponer otra vez",
"alarmRingingNotificationTitle": "Alarma PluriWave",
"alarmFireChannelName": "Alarmas sonando",
"alarmFireChannelDescription": "Sonido y pantalla urgente cuando una alarma musical debe sonar",
"alarmPreNoticeChannelName": "Preavisos de alarmas",
"alarmPreNoticeChannelDescription": "Notificaciones silenciosas antes de la alarma",
"openFolderChooserTitle": "Abrir carpeta",
"openRecordingChooserTitle": "Abrir grabación",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"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",
"alarmRingingNotificationTitle": "Alarme PluriWave",
"alarmFireChannelName": "Alarmes en cours",
"alarmFireChannelDescription": "Son et écran urgents lorsqu'une alarme musicale doit sonner",
"alarmPreNoticeChannelName": "Rappels d'alarme",
"alarmPreNoticeChannelDescription": "Notifications silencieuses avant l'alarme",
"openFolderChooserTitle": "Ouvrir le dossier",
"openRecordingChooserTitle": "Ouvrir l'enregistrement",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "{minutes} मिनट में शुरू होगा", "preNoticeCountdown": "{minutes} मिनट में शुरू होगा",
"snoozeCountdown": "{minutes} मिनट में बजेगा",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "फिर से स्नूज़ करें",
"alarmRingingNotificationTitle": "PluriWave अलार्म",
"alarmFireChannelName": "बजते अलार्म",
"alarmFireChannelDescription": "जब कोई संगीत अलार्म बजना हो तो तत्काल ध्वनि और स्क्रीन",
"alarmPreNoticeChannelName": "अलार्म रिमाइंडर",
"alarmPreNoticeChannelDescription": "अलार्म से पहले मूक सूचनाएँ",
"openFolderChooserTitle": "फ़ोल्डर खोलें",
"openRecordingChooserTitle": "रिकॉर्डिंग खोलें",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "Mulai dalam {minutes} menit", "preNoticeCountdown": "Mulai dalam {minutes} menit",
"snoozeCountdown": "Berbunyi dalam {minutes} mnt",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Tunda lagi",
"alarmRingingNotificationTitle": "Alarm PluriWave",
"alarmFireChannelName": "Alarm berbunyi",
"alarmFireChannelDescription": "Suara dan layar mendesak saat alarm musik harus berbunyi",
"alarmPreNoticeChannelName": "Pengingat alarm",
"alarmPreNoticeChannelDescription": "Notifikasi senyap sebelum alarm",
"openFolderChooserTitle": "Buka folder",
"openRecordingChooserTitle": "Buka rekaman",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "Inizia tra {minutes} min", "preNoticeCountdown": "Inizia tra {minutes} min",
"snoozeCountdown": "Suona tra {minutes} min",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Posponi di nuovo",
"alarmRingingNotificationTitle": "Sveglia PluriWave",
"alarmFireChannelName": "Sveglie in corso",
"alarmFireChannelDescription": "Suono e schermo urgenti quando una sveglia musicale deve suonare",
"alarmPreNoticeChannelName": "Promemoria sveglia",
"alarmPreNoticeChannelDescription": "Notifiche silenziose prima della sveglia",
"openFolderChooserTitle": "Apri cartella",
"openRecordingChooserTitle": "Apri registrazione",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "{minutes}分後に開始", "preNoticeCountdown": "{minutes}分後に開始",
"snoozeCountdown": "{minutes}分後に鳴ります",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "もう一度スヌーズ",
"alarmRingingNotificationTitle": "PluriWave アラーム",
"alarmFireChannelName": "鳴っているアラーム",
"alarmFireChannelDescription": "音楽アラームが鳴るときの緊急の音と画面",
"alarmPreNoticeChannelName": "アラームの事前通知",
"alarmPreNoticeChannelDescription": "アラーム前のサイレント通知",
"openFolderChooserTitle": "フォルダを開く",
"openRecordingChooserTitle": "録音を開く",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"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",
"alarmRingingNotificationTitle": "Alarme PluriWave",
"alarmFireChannelName": "Alarmes tocando",
"alarmFireChannelDescription": "Som e tela urgentes quando um alarme musical deve tocar",
"alarmPreNoticeChannelName": "Avisos de alarme",
"alarmPreNoticeChannelDescription": "Notificações silenciosas antes do alarme",
"openFolderChooserTitle": "Abrir pasta",
"openRecordingChooserTitle": "Abrir gravação",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "Начнётся через {minutes} мин", "preNoticeCountdown": "Начнётся через {minutes} мин",
"snoozeCountdown": "Прозвонит через {minutes} мин",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "Отложить снова",
"alarmRingingNotificationTitle": "Будильник PluriWave",
"alarmFireChannelName": "Звонящие будильники",
"alarmFireChannelDescription": "Срочный звук и экран, когда должен сработать музыкальный будильник",
"alarmPreNoticeChannelName": "Напоминания о будильнике",
"alarmPreNoticeChannelDescription": "Беззвучные уведомления перед будильником",
"openFolderChooserTitle": "Открыть папку",
"openRecordingChooserTitle": "Открыть запись",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+16
View File
@@ -629,6 +629,22 @@
} }
}, },
"preNoticeCountdown": "{minutes}分钟后开始", "preNoticeCountdown": "{minutes}分钟后开始",
"snoozeCountdown": "{minutes}分钟后响铃",
"@snoozeCountdown": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"snoozeAgainAction": "再次小睡",
"alarmRingingNotificationTitle": "PluriWave 闹钟",
"alarmFireChannelName": "响铃的闹钟",
"alarmFireChannelDescription": "音乐闹钟需要响起时的紧急声音和屏幕",
"alarmPreNoticeChannelName": "闹钟预告",
"alarmPreNoticeChannelDescription": "闹钟前的静音通知",
"openFolderChooserTitle": "打开文件夹",
"openRecordingChooserTitle": "打开录音",
"@preNoticeCountdown": { "@preNoticeCountdown": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
+54
View File
@@ -2312,6 +2312,60 @@ 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 @alarmRingingNotificationTitle.
///
/// In es, this message translates to:
/// **'Alarma PluriWave'**
String get alarmRingingNotificationTitle;
/// No description provided for @alarmFireChannelName.
///
/// In es, this message translates to:
/// **'Alarmas sonando'**
String get alarmFireChannelName;
/// No description provided for @alarmFireChannelDescription.
///
/// In es, this message translates to:
/// **'Sonido y pantalla urgente cuando una alarma musical debe sonar'**
String get alarmFireChannelDescription;
/// No description provided for @alarmPreNoticeChannelName.
///
/// In es, this message translates to:
/// **'Preavisos de alarmas'**
String get alarmPreNoticeChannelName;
/// No description provided for @alarmPreNoticeChannelDescription.
///
/// In es, this message translates to:
/// **'Notificaciones silenciosas antes de la alarma'**
String get alarmPreNoticeChannelDescription;
/// No description provided for @openFolderChooserTitle.
///
/// In es, this message translates to:
/// **'Abrir carpeta'**
String get openFolderChooserTitle;
/// No description provided for @openRecordingChooserTitle.
///
/// In es, this message translates to:
/// **'Abrir grabación'**
String get openRecordingChooserTitle;
/// No description provided for @eqDeviceEditTitle. /// No description provided for @eqDeviceEditTitle.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
+30
View File
@@ -1253,6 +1253,36 @@ class AppLocalizationsAr extends AppLocalizations {
return 'يبدأ خلال $minutes دقيقة'; return 'يبدأ خلال $minutes دقيقة';
} }
@override
String snoozeCountdown(int minutes) {
return 'يرنّ خلال $minutes دقيقة';
}
@override
String get snoozeAgainAction => 'غفوة مرة أخرى';
@override
String get alarmRingingNotificationTitle => 'منبّه PluriWave';
@override
String get alarmFireChannelName => 'المنبّهات الرنّانة';
@override
String get alarmFireChannelDescription =>
'صوت وشاشة عاجلة عندما يجب أن يرنّ منبّه موسيقي';
@override
String get alarmPreNoticeChannelName => 'تنبيهات المنبّهات';
@override
String get alarmPreNoticeChannelDescription => 'إشعارات صامتة قبل المنبّه';
@override
String get openFolderChooserTitle => 'فتح المجلد';
@override
String get openRecordingChooserTitle => 'فتح التسجيل';
@override @override
String get eqDeviceEditTitle => 'تعديل الجهاز'; String get eqDeviceEditTitle => 'تعديل الجهاز';
+31
View File
@@ -1260,6 +1260,37 @@ class AppLocalizationsBn extends AppLocalizations {
return '$minutes মিনিটে শুরু হবে'; return '$minutes মিনিটে শুরু হবে';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes মিনিটে বাজবে';
}
@override
String get snoozeAgainAction => 'আবার স্নুজ করুন';
@override
String get alarmRingingNotificationTitle => 'PluriWave অ্যালার্ম';
@override
String get alarmFireChannelName => 'বাজতে থাকা অ্যালার্ম';
@override
String get alarmFireChannelDescription =>
'কোনো মিউজিক অ্যালার্ম বাজার সময় জরুরি শব্দ ও স্ক্রিন';
@override
String get alarmPreNoticeChannelName => 'অ্যালার্ম রিমাইন্ডার';
@override
String get alarmPreNoticeChannelDescription =>
'অ্যালার্মের আগে নীরব বিজ্ঞপ্তি';
@override
String get openFolderChooserTitle => 'ফোল্ডার খুলুন';
@override
String get openRecordingChooserTitle => 'রেকর্ডিং খুলুন';
@override @override
String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন'; String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন';
+31
View File
@@ -1270,6 +1270,37 @@ 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
String get alarmRingingNotificationTitle => 'PluriWave-Wecker';
@override
String get alarmFireChannelName => 'Klingelnde Wecker';
@override
String get alarmFireChannelDescription =>
'Dringender Ton und Bildschirm, wenn ein Musikwecker klingeln soll';
@override
String get alarmPreNoticeChannelName => 'Wecker-Vorankündigungen';
@override
String get alarmPreNoticeChannelDescription =>
'Lautlose Benachrichtigungen vor dem Wecker';
@override
String get openFolderChooserTitle => 'Ordner öffnen';
@override
String get openRecordingChooserTitle => 'Aufnahme öffnen';
@override @override
String get eqDeviceEditTitle => 'Gerät bearbeiten'; String get eqDeviceEditTitle => 'Gerät bearbeiten';
+31
View File
@@ -1256,6 +1256,37 @@ 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
String get alarmRingingNotificationTitle => 'PluriWave alarm';
@override
String get alarmFireChannelName => 'Ringing alarms';
@override
String get alarmFireChannelDescription =>
'Urgent sound and screen when a music alarm must ring';
@override
String get alarmPreNoticeChannelName => 'Alarm reminders';
@override
String get alarmPreNoticeChannelDescription =>
'Silent notifications before the alarm';
@override
String get openFolderChooserTitle => 'Open folder';
@override
String get openRecordingChooserTitle => 'Open recording';
@override @override
String get eqDeviceEditTitle => 'Edit device'; String get eqDeviceEditTitle => 'Edit device';
+31
View File
@@ -1265,6 +1265,37 @@ 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
String get alarmRingingNotificationTitle => 'Alarma PluriWave';
@override
String get alarmFireChannelName => 'Alarmas sonando';
@override
String get alarmFireChannelDescription =>
'Sonido y pantalla urgente cuando una alarma musical debe sonar';
@override
String get alarmPreNoticeChannelName => 'Preavisos de alarmas';
@override
String get alarmPreNoticeChannelDescription =>
'Notificaciones silenciosas antes de la alarma';
@override
String get openFolderChooserTitle => 'Abrir carpeta';
@override
String get openRecordingChooserTitle => 'Abrir grabación';
@override @override
String get eqDeviceEditTitle => 'Editar dispositivo'; String get eqDeviceEditTitle => 'Editar dispositivo';
+31
View File
@@ -1275,6 +1275,37 @@ 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
String get alarmRingingNotificationTitle => 'Alarme PluriWave';
@override
String get alarmFireChannelName => 'Alarmes en cours';
@override
String get alarmFireChannelDescription =>
'Son et écran urgents lorsqu\'une alarme musicale doit sonner';
@override
String get alarmPreNoticeChannelName => 'Rappels d\'alarme';
@override
String get alarmPreNoticeChannelDescription =>
'Notifications silencieuses avant l\'alarme';
@override
String get openFolderChooserTitle => 'Ouvrir le dossier';
@override
String get openRecordingChooserTitle => 'Ouvrir l\'enregistrement';
@override @override
String get eqDeviceEditTitle => 'Modifier l\'appareil'; String get eqDeviceEditTitle => 'Modifier l\'appareil';
+30
View File
@@ -1259,6 +1259,36 @@ class AppLocalizationsHi extends AppLocalizations {
return '$minutes मिनट में शुरू होगा'; return '$minutes मिनट में शुरू होगा';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes मिनट में बजेगा';
}
@override
String get snoozeAgainAction => 'फिर से स्नूज़ करें';
@override
String get alarmRingingNotificationTitle => 'PluriWave अलार्म';
@override
String get alarmFireChannelName => 'बजते अलार्म';
@override
String get alarmFireChannelDescription =>
'जब कोई संगीत अलार्म बजना हो तो तत्काल ध्वनि और स्क्रीन';
@override
String get alarmPreNoticeChannelName => 'अलार्म रिमाइंडर';
@override
String get alarmPreNoticeChannelDescription => 'अलार्म से पहले मूक सूचनाएँ';
@override
String get openFolderChooserTitle => 'फ़ोल्डर खोलें';
@override
String get openRecordingChooserTitle => 'रिकॉर्डिंग खोलें';
@override @override
String get eqDeviceEditTitle => 'डिवाइस संपादित करें'; String get eqDeviceEditTitle => 'डिवाइस संपादित करें';
+31
View File
@@ -1264,6 +1264,37 @@ 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
String get alarmRingingNotificationTitle => 'Alarm PluriWave';
@override
String get alarmFireChannelName => 'Alarm berbunyi';
@override
String get alarmFireChannelDescription =>
'Suara dan layar mendesak saat alarm musik harus berbunyi';
@override
String get alarmPreNoticeChannelName => 'Pengingat alarm';
@override
String get alarmPreNoticeChannelDescription =>
'Notifikasi senyap sebelum alarm';
@override
String get openFolderChooserTitle => 'Buka folder';
@override
String get openRecordingChooserTitle => 'Buka rekaman';
@override @override
String get eqDeviceEditTitle => 'Edit perangkat'; String get eqDeviceEditTitle => 'Edit perangkat';
+31
View File
@@ -1270,6 +1270,37 @@ 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
String get alarmRingingNotificationTitle => 'Sveglia PluriWave';
@override
String get alarmFireChannelName => 'Sveglie in corso';
@override
String get alarmFireChannelDescription =>
'Suono e schermo urgenti quando una sveglia musicale deve suonare';
@override
String get alarmPreNoticeChannelName => 'Promemoria sveglia';
@override
String get alarmPreNoticeChannelDescription =>
'Notifiche silenziose prima della sveglia';
@override
String get openFolderChooserTitle => 'Apri cartella';
@override
String get openRecordingChooserTitle => 'Apri registrazione';
@override @override
String get eqDeviceEditTitle => 'Modifica dispositivo'; String get eqDeviceEditTitle => 'Modifica dispositivo';
+29
View File
@@ -1220,6 +1220,35 @@ class AppLocalizationsJa extends AppLocalizations {
return '$minutes分後に開始'; return '$minutes分後に開始';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes分後に鳴ります';
}
@override
String get snoozeAgainAction => 'もう一度スヌーズ';
@override
String get alarmRingingNotificationTitle => 'PluriWave アラーム';
@override
String get alarmFireChannelName => '鳴っているアラーム';
@override
String get alarmFireChannelDescription => '音楽アラームが鳴るときの緊急の音と画面';
@override
String get alarmPreNoticeChannelName => 'アラームの事前通知';
@override
String get alarmPreNoticeChannelDescription => 'アラーム前のサイレント通知';
@override
String get openFolderChooserTitle => 'フォルダを開く';
@override
String get openRecordingChooserTitle => '録音を開く';
@override @override
String get eqDeviceEditTitle => 'デバイスを編集'; String get eqDeviceEditTitle => 'デバイスを編集';
+31
View File
@@ -1262,6 +1262,37 @@ 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
String get alarmRingingNotificationTitle => 'Alarme PluriWave';
@override
String get alarmFireChannelName => 'Alarmes tocando';
@override
String get alarmFireChannelDescription =>
'Som e tela urgentes quando um alarme musical deve tocar';
@override
String get alarmPreNoticeChannelName => 'Avisos de alarme';
@override
String get alarmPreNoticeChannelDescription =>
'Notificações silenciosas antes do alarme';
@override
String get openFolderChooserTitle => 'Abrir pasta';
@override
String get openRecordingChooserTitle => 'Abrir gravação';
@override @override
String get eqDeviceEditTitle => 'Editar dispositivo'; String get eqDeviceEditTitle => 'Editar dispositivo';
+31
View File
@@ -1266,6 +1266,37 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Начнётся через $minutes мин'; return 'Начнётся через $minutes мин';
} }
@override
String snoozeCountdown(int minutes) {
return 'Прозвонит через $minutes мин';
}
@override
String get snoozeAgainAction => 'Отложить снова';
@override
String get alarmRingingNotificationTitle => 'Будильник PluriWave';
@override
String get alarmFireChannelName => 'Звонящие будильники';
@override
String get alarmFireChannelDescription =>
'Срочный звук и экран, когда должен сработать музыкальный будильник';
@override
String get alarmPreNoticeChannelName => 'Напоминания о будильнике';
@override
String get alarmPreNoticeChannelDescription =>
'Беззвучные уведомления перед будильником';
@override
String get openFolderChooserTitle => 'Открыть папку';
@override
String get openRecordingChooserTitle => 'Открыть запись';
@override @override
String get eqDeviceEditTitle => 'Изменить устройство'; String get eqDeviceEditTitle => 'Изменить устройство';
+29
View File
@@ -1213,6 +1213,35 @@ class AppLocalizationsZh extends AppLocalizations {
return '$minutes分钟后开始'; return '$minutes分钟后开始';
} }
@override
String snoozeCountdown(int minutes) {
return '$minutes分钟后响铃';
}
@override
String get snoozeAgainAction => '再次小睡';
@override
String get alarmRingingNotificationTitle => 'PluriWave 闹钟';
@override
String get alarmFireChannelName => '响铃的闹钟';
@override
String get alarmFireChannelDescription => '音乐闹钟需要响起时的紧急声音和屏幕';
@override
String get alarmPreNoticeChannelName => '闹钟预告';
@override
String get alarmPreNoticeChannelDescription => '闹钟前的静音通知';
@override
String get openFolderChooserTitle => '打开文件夹';
@override
String get openRecordingChooserTitle => '打开录音';
@override @override
String get eqDeviceEditTitle => '编辑设备'; String get eqDeviceEditTitle => '编辑设备';
+38 -8
View File
@@ -154,10 +154,17 @@ 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
await alarmas.finalizarEjecucion(widget.alarma.id); // leave the ringing screen stuck open (which would also block the next
if (mounted) _dismissScreen(); // 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 /// Flutter-first snooze (S2-R1): tears down local audio, then routes
@@ -166,10 +173,33 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Future<void> _posponer(int minutos) async { 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
await alarmas.posponerAlarma(widget.alarma, minutos); // (e.g. native scheduleAlarm returns false on a device without exact
if (mounted) _dismissScreen(); // 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. /// Dismisses the alarm screen safely in both live-app and dead-app states.
+36 -11
View File
@@ -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;
@@ -168,19 +172,41 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
@override @override
void configurarLocalizaciones(AppLocalizations l10n) { void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n; _l10n = l10n;
// Push every localized notification/channel/chooser string to the native
// side so it can build localized notifications even when the Flutter engine
// is dead (alarm fired from a killed app). Fire-and-forget; runs once per
// locale change (callers guard against per-rebuild churn).
unawaited(_enviarTextosNotificacion(l10n));
} }
/// Builds a pre-notice template string with a literal `{minutes}` placeholder Future<void> _enviarTextosNotificacion(AppLocalizations l10n) async {
/// for Kotlin to replace at broadcast-receiver fire time. try {
/// await _channel.invokeMethod<void>('setNotificationStrings', {
/// Strategy: call [preNoticeCountdown] with a unique sentinel integer and 'ringTitle': l10n.alarmRingingNotificationTitle,
/// replace the sentinel's string representation with `{minutes}`. 'snoozeLabel': l10n.snoozeAction,
static String _preNoticeTemplate(AppLocalizations l10n) { 'stopLabel': l10n.stopAlarmAction,
'skipLabel': l10n.skipNextAction,
'snoozeAgainLabel': l10n.snoozeAgainAction,
'fireChannelName': l10n.alarmFireChannelName,
'fireChannelDescription': l10n.alarmFireChannelDescription,
'preNoticeChannelName': l10n.alarmPreNoticeChannelName,
'preNoticeChannelDescription': l10n.alarmPreNoticeChannelDescription,
'preNoticeTemplate': _plantillaMinutos(l10n.preNoticeCountdown),
'snoozeCountdownTemplate': _plantillaMinutos(l10n.snoozeCountdown),
'openFolderTitle': l10n.openFolderChooserTitle,
'openRecordingTitle': l10n.openRecordingChooserTitle,
});
} catch (e) {
debugPrint('[PluriWave][alarmas] setNotificationStrings ERROR $e');
}
}
/// Turns a localized `{int} -> String` message into a template with a literal
/// `{minutes}` placeholder for Kotlin to fill at fire time: it calls the
/// message with a unique sentinel and swaps the sentinel back for `{minutes}`.
static String _plantillaMinutos(String Function(int) traducir) {
const sentinel = 42424242; const sentinel = 42424242;
return l10n.preNoticeCountdown(sentinel).replaceFirst( return traducir(sentinel).replaceFirst(sentinel.toString(), '{minutes}');
sentinel.toString(),
'{minutes}',
);
} }
@override @override
@@ -202,7 +228,6 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', { final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
'id': alarma.id, 'id': alarma.id,
'title': localizedAlarmName(_textos, alarma.nombre), 'title': localizedAlarmName(_textos, alarma.nombre),
'preNoticeTemplate': _preNoticeTemplate(_textos),
'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',
);
},
);
} }
@@ -1,5 +1,8 @@
import 'dart:ui' show Locale;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
@@ -25,55 +28,75 @@ void main() {
}); });
test( test(
'programar includes preNoticeTemplate with {minutes} placeholder in MethodChannel call', 'configurarLocalizaciones pushes localized notification strings with {minutes} templates',
() async { () async {
final servicio = ServicioAlarmasAndroid(channel: channel); final servicio = ServicioAlarmasAndroid(channel: channel);
final alarma = AlarmaMusical( final l10n = lookupAppLocalizations(const Locale('es'));
id: 'test-alarm',
nombre: 'Morning alarm', servicio.configurarLocalizaciones(l10n);
hora: 7, // configurarLocalizaciones fires setNotificationStrings as unawaited; let
minuto: 0, // the microtask/event queue drain so the MethodChannel call is recorded.
tipoProgramacion: TipoProgramacionAlarma.diaria, await Future<void>.delayed(Duration.zero);
diasSemana: const [],
proximaEjecucion: DateTime(2099, 1, 1, 7, 0), final llamada = llamadas.singleWhere(
(c) => c.method == 'setNotificationStrings',
);
final args = llamada.arguments as Map<Object?, Object?>;
expect(
args['preNoticeTemplate'],
contains('{minutes}'),
reason: 'pre-notice template must keep the {minutes} placeholder',
);
expect(
args['snoozeCountdownTemplate'],
contains('{minutes}'),
reason: 'snooze countdown template must keep the {minutes} placeholder',
); );
await servicio.programar(alarma); // Every notification/channel/chooser string must be present and non-empty
// so the native side never falls back to English for a configured locale.
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm'); const claves = [
final args = llamada.arguments as Map<Object?, Object?>; 'ringTitle',
expect(args.containsKey('preNoticeTemplate'), isTrue, 'snoozeLabel',
reason: 'preNoticeTemplate must be present in scheduleAlarm args'); 'stopLabel',
final template = args['preNoticeTemplate'] as String?; 'skipLabel',
expect(template, isNotNull, 'snoozeAgainLabel',
reason: 'preNoticeTemplate must not be null'); 'fireChannelName',
expect(template, contains('{minutes}'), 'fireChannelDescription',
reason: 'preNoticeTemplate must contain the {minutes} placeholder'); 'preNoticeChannelName',
'preNoticeChannelDescription',
'openFolderTitle',
'openRecordingTitle',
];
for (final clave in claves) {
expect(args[clave], isA<String>(), reason: '$clave missing');
expect(
(args[clave] as String).isNotEmpty,
isTrue,
reason: '$clave is empty',
);
}
}, },
); );
test( test('scheduleAlarm no longer carries notification string templates', () async {
'programar preNoticeTemplate uses default locale fallback when no l10n configured', final servicio = ServicioAlarmasAndroid(channel: channel);
() async { final alarma = AlarmaMusical(
// ServicioAlarmasAndroid falls back to es locale when no l10n is configured id: 'no-template',
final servicio = ServicioAlarmasAndroid(channel: channel); nombre: 'Alarm',
final alarma = AlarmaMusical( hora: 8,
id: 'test-alarm-2', minuto: 30,
nombre: 'Alarm', tipoProgramacion: TipoProgramacionAlarma.diaria,
hora: 8, diasSemana: const [],
minuto: 30, proximaEjecucion: DateTime(2099, 1, 2, 8, 30),
tipoProgramacion: TipoProgramacionAlarma.diaria, );
diasSemana: const [],
proximaEjecucion: DateTime(2099, 1, 2, 8, 30),
);
await servicio.programar(alarma); await servicio.programar(alarma);
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm'); final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm');
final args = llamada.arguments as Map<Object?, Object?>; final args = llamada.arguments as Map<Object?, Object?>;
final template = args['preNoticeTemplate'] as String?; expect(args.containsKey('preNoticeTemplate'), isFalse);
// The template must contain the literal placeholder string expect(args.containsKey('snoozeCountdownTemplate'), isFalse);
expect(template, contains('{minutes}')); });
},
);
} }