Compare commits
2
Commits
89ff6a3912
...
ffd09a2179
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd09a2179 | ||
|
|
481944815f |
@@ -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
|
||||
|
||||
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
|
||||
@@ -39,8 +42,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
snoozeMinutes: Int = 5,
|
||||
fallbackStationName: String? = null,
|
||||
fallbackStationUrl: String? = null,
|
||||
fadeInSegundos: Int = 0,
|
||||
preNoticeTemplate: String? = null
|
||||
fadeInSegundos: Int = 0
|
||||
): Boolean {
|
||||
val existing = readSpec(id)
|
||||
val preservedSnooze = preserveNativeSnooze(
|
||||
@@ -71,8 +73,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
fallbackSound = fallbackSound,
|
||||
volume = volume.coerceIn(0f, 1f),
|
||||
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
|
||||
timezoneId = TimeZone.getDefault().id,
|
||||
preNoticeTemplate = preNoticeTemplate
|
||||
timezoneId = TimeZone.getDefault().id
|
||||
)
|
||||
return scheduleSpec(spec, persistOnSuccess = true)
|
||||
}
|
||||
@@ -118,7 +119,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
|
||||
}
|
||||
|
||||
@@ -147,7 +160,6 @@ class AlarmScheduler(private val context: Context) {
|
||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||
)
|
||||
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
@@ -168,7 +180,6 @@ class AlarmScheduler(private val context: Context) {
|
||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||
)
|
||||
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
|
||||
}
|
||||
)
|
||||
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
|
||||
@@ -231,6 +242,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 +332,225 @@ 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 = 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) {
|
||||
Log.d(tag, "alarm.cancel id=$id")
|
||||
removeScheduledAlarm(id)
|
||||
@@ -322,6 +558,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)
|
||||
)
|
||||
@@ -656,10 +893,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
val fallbackSound: String?,
|
||||
val volume: Float,
|
||||
val fadeInSegundos: Int = 0,
|
||||
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 timezoneId: String
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("schemaVersion", 3)
|
||||
@@ -686,7 +920,6 @@ class AlarmScheduler(private val context: Context) {
|
||||
put("volume", volume)
|
||||
put("fadeInSegundos", fadeInSegundos)
|
||||
put("timezoneId", timezoneId)
|
||||
put("preNoticeTemplate", preNoticeTemplate)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -723,8 +956,7 @@ class AlarmScheduler(private val context: Context) {
|
||||
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
|
||||
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() }
|
||||
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -739,9 +971,6 @@ class AlarmScheduler(private val context: Context) {
|
||||
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
|
||||
private const val SCHEDULE_UNICA = "unica"
|
||||
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,
|
||||
fallbackStationName = call.argument<String>("fallbackStationName"),
|
||||
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
|
||||
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0,
|
||||
preNoticeTemplate = call.argument<String>("preNoticeTemplate")
|
||||
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
|
||||
)
|
||||
result.success(scheduled)
|
||||
}
|
||||
@@ -206,6 +205,16 @@ class MainActivity : AudioServiceActivity() {
|
||||
Log.d(tag, "alarm.channel getNativeSnoozeState")
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -424,7 +433,9 @@ class MainActivity : AudioServiceActivity() {
|
||||
|
||||
for (intent in candidates) {
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, "Abrir carpeta"))
|
||||
startActivity(
|
||||
Intent.createChooser(intent, AlarmNotificationStrings.openFolderTitle(this))
|
||||
)
|
||||
Log.d(tag, "file_actions.viewDirectory launched path=$path")
|
||||
return true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
@@ -468,7 +479,9 @@ class MainActivity : AudioServiceActivity() {
|
||||
clipData = ClipData.newUri(contentResolver, "recording", uri)
|
||||
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")
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
@@ -767,6 +780,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
|
||||
|
||||
|
||||
@@ -51,8 +51,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
title,
|
||||
snoozeMinutes,
|
||||
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
|
||||
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L),
|
||||
intent.getStringExtra(AlarmScheduler.EXTRA_PRE_NOTICE_TEMPLATE)
|
||||
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
|
||||
)
|
||||
}
|
||||
ACTION_POSTPONE_NEXT -> {
|
||||
@@ -91,6 +90,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")
|
||||
}
|
||||
}
|
||||
@@ -101,13 +134,12 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
title: String,
|
||||
snoozeMinutes: Int,
|
||||
triggerAtMillis: Long,
|
||||
occurrenceAtMillis: Long,
|
||||
preNoticeTemplate: String? = null
|
||||
occurrenceAtMillis: Long
|
||||
) {
|
||||
ensureChannel(context)
|
||||
|
||||
val remaining = computeRemainingMinutes(triggerAtMillis)
|
||||
val contentText = formatPreNoticeText(preNoticeTemplate, remaining)
|
||||
val contentText = AlarmNotificationStrings.preNoticeText(context, remaining)
|
||||
|
||||
val openAppIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
@@ -155,8 +187,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(openAppIntent)
|
||||
.addAction(0, "Posponer", postponeNextIntent)
|
||||
.addAction(0, "Omitir esta vez", skipNextIntent)
|
||||
.addAction(0, AlarmNotificationStrings.snoozeLabel(context), postponeNextIntent)
|
||||
.addAction(0, AlarmNotificationStrings.skipLabel(context), skipNextIntent)
|
||||
.build()
|
||||
|
||||
try {
|
||||
@@ -174,30 +206,17 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
|
||||
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) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val existing = manager.getNotificationChannel(CHANNEL_ID)
|
||||
if (existing != null) return
|
||||
|
||||
// Re-create each time so the localized name/description refresh after a
|
||||
// locale change (Android updates them on an existing channel).
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Preavisos de alarmas",
|
||||
AlarmNotificationStrings.preNoticeChannelName(context),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Notificaciones silenciosas 30 minutos antes de la alarma"
|
||||
description = AlarmNotificationStrings.preNoticeChannelDescription(context)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
@@ -216,6 +235,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"
|
||||
|
||||
@@ -388,7 +388,7 @@ class PluriWaveAlarmService : Service() {
|
||||
) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||
.setContentTitle("Alarma PluriWave")
|
||||
.setContentTitle(AlarmNotificationStrings.ringTitle(this))
|
||||
.setContentText(
|
||||
if (stationName.isNullOrBlank()) title else "$title - $stationName"
|
||||
)
|
||||
@@ -399,8 +399,8 @@ class PluriWaveAlarmService : Service() {
|
||||
.setAutoCancel(false)
|
||||
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
|
||||
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
|
||||
.addAction(0, "Posponer", snoozePendingIntent(alarmId, snoozeMinutes))
|
||||
.addAction(0, "Detener", stopPendingIntent(alarmId))
|
||||
.addAction(0, AlarmNotificationStrings.snoozeLabel(this), snoozePendingIntent(alarmId, snoozeMinutes))
|
||||
.addAction(0, AlarmNotificationStrings.stopLabel(this), stopPendingIntent(alarmId))
|
||||
.build()
|
||||
|
||||
private fun openAlarmPendingIntent(
|
||||
@@ -553,13 +553,16 @@ class PluriWaveAlarmService : Service() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
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(
|
||||
CHANNEL_ID,
|
||||
"Alarmas sonando",
|
||||
AlarmNotificationStrings.fireChannelName(context),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Sonido y pantalla urgente cuando una alarma musical debe sonar"
|
||||
description = AlarmNotificationStrings.fireChannelDescription(context)
|
||||
enableVibration(true)
|
||||
setSound(
|
||||
Settings.System.DEFAULT_ALARM_ALERT_URI,
|
||||
|
||||
@@ -281,6 +281,10 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
/// The native scheduler already re-registered setAlarmClock, so this only
|
||||
/// persists the canonical state — it MUST NOT call android.programar again.
|
||||
Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async {
|
||||
if (evento.accion == EventoAlarmaAndroid.accionSnoozeCancelled) {
|
||||
await _registrarCancelacionSnoozeNativa(evento);
|
||||
return;
|
||||
}
|
||||
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
|
||||
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
|
||||
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
|
||||
@@ -304,6 +308,30 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors a native snooze cancellation ("Detener" on the countdown
|
||||
/// notification). The native scheduler already advanced to the next normal
|
||||
/// occurrence, so this only clears the snooze in the canonical config and
|
||||
/// MUST NOT call android.programar again (would double-schedule).
|
||||
Future<void> _registrarCancelacionSnoozeNativa(
|
||||
EventoAlarmaAndroid evento,
|
||||
) async {
|
||||
if (evento.alarmaId.isEmpty) return;
|
||||
final origen =
|
||||
evento.occurrenceAtMillis > 0
|
||||
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
||||
: DateTime.now();
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] snooze cancelado nativo id=${evento.alarmaId} origen=${origen.toIso8601String()}',
|
||||
);
|
||||
try {
|
||||
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
|
||||
_aplicar(config);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] cancelar snooze nativo ERROR $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
|
||||
try {
|
||||
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "يبدأ خلال {minutes} دقيقة",
|
||||
"snoozeCountdown": "يرنّ خلال {minutes} دقيقة",
|
||||
"@snoozeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snoozeAgainAction": "غفوة مرة أخرى",
|
||||
"alarmRingingNotificationTitle": "منبّه PluriWave",
|
||||
"alarmFireChannelName": "المنبّهات الرنّانة",
|
||||
"alarmFireChannelDescription": "صوت وشاشة عاجلة عندما يجب أن يرنّ منبّه موسيقي",
|
||||
"alarmPreNoticeChannelName": "تنبيهات المنبّهات",
|
||||
"alarmPreNoticeChannelDescription": "إشعارات صامتة قبل المنبّه",
|
||||
"openFolderChooserTitle": "فتح المجلد",
|
||||
"openRecordingChooserTitle": "فتح التسجيل",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes} মিনিটে শুরু হবে",
|
||||
"snoozeCountdown": "{minutes} মিনিটে বাজবে",
|
||||
"@snoozeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snoozeAgainAction": "আবার স্নুজ করুন",
|
||||
"alarmRingingNotificationTitle": "PluriWave অ্যালার্ম",
|
||||
"alarmFireChannelName": "বাজতে থাকা অ্যালার্ম",
|
||||
"alarmFireChannelDescription": "কোনো মিউজিক অ্যালার্ম বাজার সময় জরুরি শব্দ ও স্ক্রিন",
|
||||
"alarmPreNoticeChannelName": "অ্যালার্ম রিমাইন্ডার",
|
||||
"alarmPreNoticeChannelDescription": "অ্যালার্মের আগে নীরব বিজ্ঞপ্তি",
|
||||
"openFolderChooserTitle": "ফোল্ডার খুলুন",
|
||||
"openRecordingChooserTitle": "রেকর্ডিং খুলুন",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -592,6 +592,22 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes} मिनट में शुरू होगा",
|
||||
"snoozeCountdown": "{minutes} मिनट में बजेगा",
|
||||
"@snoozeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snoozeAgainAction": "फिर से स्नूज़ करें",
|
||||
"alarmRingingNotificationTitle": "PluriWave अलार्म",
|
||||
"alarmFireChannelName": "बजते अलार्म",
|
||||
"alarmFireChannelDescription": "जब कोई संगीत अलार्म बजना हो तो तत्काल ध्वनि और स्क्रीन",
|
||||
"alarmPreNoticeChannelName": "अलार्म रिमाइंडर",
|
||||
"alarmPreNoticeChannelDescription": "अलार्म से पहले मूक सूचनाएँ",
|
||||
"openFolderChooserTitle": "फ़ोल्डर खोलें",
|
||||
"openRecordingChooserTitle": "रिकॉर्डिंग खोलें",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes}分後に開始",
|
||||
"snoozeCountdown": "{minutes}分後に鳴ります",
|
||||
"@snoozeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snoozeAgainAction": "もう一度スヌーズ",
|
||||
"alarmRingingNotificationTitle": "PluriWave アラーム",
|
||||
"alarmFireChannelName": "鳴っているアラーム",
|
||||
"alarmFireChannelDescription": "音楽アラームが鳴るときの緊急の音と画面",
|
||||
"alarmPreNoticeChannelName": "アラームの事前通知",
|
||||
"alarmPreNoticeChannelDescription": "アラーム前のサイレント通知",
|
||||
"openFolderChooserTitle": "フォルダを開く",
|
||||
"openRecordingChooserTitle": "録音を開く",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "Начнётся через {minutes} мин",
|
||||
"snoozeCountdown": "Прозвонит через {minutes} мин",
|
||||
"@snoozeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snoozeAgainAction": "Отложить снова",
|
||||
"alarmRingingNotificationTitle": "Будильник PluriWave",
|
||||
"alarmFireChannelName": "Звонящие будильники",
|
||||
"alarmFireChannelDescription": "Срочный звук и экран, когда должен сработать музыкальный будильник",
|
||||
"alarmPreNoticeChannelName": "Напоминания о будильнике",
|
||||
"alarmPreNoticeChannelDescription": "Беззвучные уведомления перед будильником",
|
||||
"openFolderChooserTitle": "Открыть папку",
|
||||
"openRecordingChooserTitle": "Открыть запись",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -629,6 +629,22 @@
|
||||
}
|
||||
},
|
||||
"preNoticeCountdown": "{minutes}分钟后开始",
|
||||
"snoozeCountdown": "{minutes}分钟后响铃",
|
||||
"@snoozeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snoozeAgainAction": "再次小睡",
|
||||
"alarmRingingNotificationTitle": "PluriWave 闹钟",
|
||||
"alarmFireChannelName": "响铃的闹钟",
|
||||
"alarmFireChannelDescription": "音乐闹钟需要响起时的紧急声音和屏幕",
|
||||
"alarmPreNoticeChannelName": "闹钟预告",
|
||||
"alarmPreNoticeChannelDescription": "闹钟前的静音通知",
|
||||
"openFolderChooserTitle": "打开文件夹",
|
||||
"openRecordingChooserTitle": "打开录音",
|
||||
"@preNoticeCountdown": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
|
||||
@@ -2312,6 +2312,60 @@ 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 @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.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
|
||||
@@ -1253,6 +1253,36 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
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
|
||||
String get eqDeviceEditTitle => 'تعديل الجهاز';
|
||||
|
||||
|
||||
@@ -1260,6 +1260,37 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
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
|
||||
String get eqDeviceEditTitle => 'ডিভাইস সম্পাদনা করুন';
|
||||
|
||||
|
||||
@@ -1270,6 +1270,37 @@ 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 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
|
||||
String get eqDeviceEditTitle => 'Gerät bearbeiten';
|
||||
|
||||
|
||||
@@ -1256,6 +1256,37 @@ 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 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
|
||||
String get eqDeviceEditTitle => 'Edit device';
|
||||
|
||||
|
||||
@@ -1265,6 +1265,37 @@ 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 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
|
||||
String get eqDeviceEditTitle => 'Editar dispositivo';
|
||||
|
||||
|
||||
@@ -1275,6 +1275,37 @@ 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 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
|
||||
String get eqDeviceEditTitle => 'Modifier l\'appareil';
|
||||
|
||||
|
||||
@@ -1259,6 +1259,36 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
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
|
||||
String get eqDeviceEditTitle => 'डिवाइस संपादित करें';
|
||||
|
||||
|
||||
@@ -1264,6 +1264,37 @@ 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 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
|
||||
String get eqDeviceEditTitle => 'Edit perangkat';
|
||||
|
||||
|
||||
@@ -1270,6 +1270,37 @@ 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 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
|
||||
String get eqDeviceEditTitle => 'Modifica dispositivo';
|
||||
|
||||
|
||||
@@ -1220,6 +1220,35 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
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
|
||||
String get eqDeviceEditTitle => 'デバイスを編集';
|
||||
|
||||
|
||||
@@ -1262,6 +1262,37 @@ 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 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
|
||||
String get eqDeviceEditTitle => 'Editar dispositivo';
|
||||
|
||||
|
||||
@@ -1266,6 +1266,37 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
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
|
||||
String get eqDeviceEditTitle => 'Изменить устройство';
|
||||
|
||||
|
||||
@@ -1213,6 +1213,35 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
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
|
||||
String get eqDeviceEditTitle => '编辑设备';
|
||||
|
||||
|
||||
@@ -154,10 +154,17 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
Future<void> _detener() async {
|
||||
final radio = context.read<EstadoRadio>();
|
||||
final alarmas = context.read<EstadoAlarmas>();
|
||||
await _liberarAudioLocal();
|
||||
await radio.audio.pausar();
|
||||
await 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<PantallaAlarmaSonando> {
|
||||
Future<void> _posponer(int minutos) async {
|
||||
final radio = context.read<EstadoRadio>();
|
||||
final alarmas = context.read<EstadoAlarmas>();
|
||||
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<void> _silenciarAudio(EstadoRadio radio) async {
|
||||
try {
|
||||
await _liberarAudioLocal();
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] liberar audio local fallo: $e');
|
||||
}
|
||||
try {
|
||||
await radio.audio.pausar();
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] pausar radio fallo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismisses the alarm screen safely in both live-app and dead-app states.
|
||||
|
||||
@@ -23,6 +23,10 @@ class EventoAlarmaAndroid {
|
||||
/// (notification "Posponer" while the app may be backgrounded/killed).
|
||||
static const accionSnoozed = 'snoozed';
|
||||
|
||||
/// Action reported when a pending snooze was cancelled natively from the
|
||||
/// countdown notification ("Detener" while the app may be killed).
|
||||
static const accionSnoozeCancelled = 'snoozeCancelled';
|
||||
|
||||
final String alarmaId;
|
||||
final String titulo;
|
||||
final String accion;
|
||||
@@ -168,19 +172,41 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
@override
|
||||
void configurarLocalizaciones(AppLocalizations 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
|
||||
/// for Kotlin to replace at broadcast-receiver fire time.
|
||||
///
|
||||
/// Strategy: call [preNoticeCountdown] with a unique sentinel integer and
|
||||
/// replace the sentinel's string representation with `{minutes}`.
|
||||
static String _preNoticeTemplate(AppLocalizations l10n) {
|
||||
Future<void> _enviarTextosNotificacion(AppLocalizations l10n) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('setNotificationStrings', {
|
||||
'ringTitle': l10n.alarmRingingNotificationTitle,
|
||||
'snoozeLabel': l10n.snoozeAction,
|
||||
'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;
|
||||
return l10n.preNoticeCountdown(sentinel).replaceFirst(
|
||||
sentinel.toString(),
|
||||
'{minutes}',
|
||||
);
|
||||
return traducir(sentinel).replaceFirst(sentinel.toString(), '{minutes}');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -202,7 +228,6 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
|
||||
'id': alarma.id,
|
||||
'title': localizedAlarmName(_textos, alarma.nombre),
|
||||
'preNoticeTemplate': _preNoticeTemplate(_textos),
|
||||
'triggerAtMillis': proxima.millisecondsSinceEpoch,
|
||||
'preNoticeAtMillis':
|
||||
alarma.snoozeHasta == null
|
||||
|
||||
@@ -248,4 +248,61 @@ void main() {
|
||||
expect(android.programadas.last.snoozeHasta, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'evento nativo snoozeCancelled limpia el snooze y avanza sin reprogramar',
|
||||
() async {
|
||||
final ahora = DateTime(2026, 6, 11, 7, 32);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'cancel1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
proximaEjecucion: DateTime(2026, 6, 11, 7, 30),
|
||||
snoozeHasta: DateTime(2026, 6, 11, 7, 35),
|
||||
snoozeOrigen: DateTime(2026, 6, 11, 7, 30),
|
||||
),
|
||||
);
|
||||
expect(estado.alarmas.single.snoozeHasta, isNotNull);
|
||||
final programadasAntes = android.programadas.length;
|
||||
final notificado = Completer<void>();
|
||||
estado.addListener(() {
|
||||
if (!notificado.isCompleted) notificado.complete();
|
||||
});
|
||||
|
||||
android.emitirEvento(
|
||||
EventoAlarmaAndroid(
|
||||
alarmaId: 'cancel1',
|
||||
titulo: 'Diaria',
|
||||
accion: EventoAlarmaAndroid.accionSnoozeCancelled,
|
||||
occurrenceAtMillis:
|
||||
DateTime(2026, 6, 11, 7, 30).millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
await notificado.future;
|
||||
|
||||
expect(estado.alarmas.single.snoozeHasta, isNull);
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 6, 12, 7, 30),
|
||||
reason: 'avanza a la próxima ocurrencia diaria',
|
||||
);
|
||||
expect(
|
||||
android.programadas.length,
|
||||
programadasAntes,
|
||||
reason: 'el nativo ya reprogramó; no debe haber un segundo programar',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:flutter/services.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/servicios/servicio_alarmas_android.dart';
|
||||
|
||||
@@ -25,55 +28,75 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'programar includes preNoticeTemplate with {minutes} placeholder in MethodChannel call',
|
||||
'configurarLocalizaciones pushes localized notification strings with {minutes} templates',
|
||||
() async {
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
final alarma = AlarmaMusical(
|
||||
id: 'test-alarm',
|
||||
nombre: 'Morning alarm',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
proximaEjecucion: DateTime(2099, 1, 1, 7, 0),
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
servicio.configurarLocalizaciones(l10n);
|
||||
// configurarLocalizaciones fires setNotificationStrings as unawaited; let
|
||||
// the microtask/event queue drain so the MethodChannel call is recorded.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
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);
|
||||
|
||||
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm');
|
||||
final args = llamada.arguments as Map<Object?, Object?>;
|
||||
expect(args.containsKey('preNoticeTemplate'), isTrue,
|
||||
reason: 'preNoticeTemplate must be present in scheduleAlarm args');
|
||||
final template = args['preNoticeTemplate'] as String?;
|
||||
expect(template, isNotNull,
|
||||
reason: 'preNoticeTemplate must not be null');
|
||||
expect(template, contains('{minutes}'),
|
||||
reason: 'preNoticeTemplate must contain the {minutes} placeholder');
|
||||
// Every notification/channel/chooser string must be present and non-empty
|
||||
// so the native side never falls back to English for a configured locale.
|
||||
const claves = [
|
||||
'ringTitle',
|
||||
'snoozeLabel',
|
||||
'stopLabel',
|
||||
'skipLabel',
|
||||
'snoozeAgainLabel',
|
||||
'fireChannelName',
|
||||
'fireChannelDescription',
|
||||
'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(
|
||||
'programar preNoticeTemplate uses default locale fallback when no l10n configured',
|
||||
() async {
|
||||
// ServicioAlarmasAndroid falls back to es locale when no l10n is configured
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
final alarma = AlarmaMusical(
|
||||
id: 'test-alarm-2',
|
||||
nombre: 'Alarm',
|
||||
hora: 8,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
proximaEjecucion: DateTime(2099, 1, 2, 8, 30),
|
||||
);
|
||||
test('scheduleAlarm no longer carries notification string templates', () async {
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
final alarma = AlarmaMusical(
|
||||
id: 'no-template',
|
||||
nombre: 'Alarm',
|
||||
hora: 8,
|
||||
minuto: 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 args = llamada.arguments as Map<Object?, Object?>;
|
||||
final template = args['preNoticeTemplate'] as String?;
|
||||
// The template must contain the literal placeholder string
|
||||
expect(template, contains('{minutes}'));
|
||||
},
|
||||
);
|
||||
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm');
|
||||
final args = llamada.arguments as Map<Object?, Object?>;
|
||||
expect(args.containsKey('preNoticeTemplate'), isFalse);
|
||||
expect(args.containsKey('snoozeCountdownTemplate'), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user