feat(alarms): add native ringing service
This commit is contained in:
@@ -49,6 +49,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".PluriWaveAlarmService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Receptor de controles de media (auriculares, notificación) -->
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
|
||||
@@ -13,7 +13,16 @@ class AlarmScheduler(private val context: Context) {
|
||||
private val alarmManager =
|
||||
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
fun scheduleAlarm(id: String, title: String, triggerAtMillis: Long, preNoticeAtMillis: Long) {
|
||||
fun scheduleAlarm(
|
||||
id: String,
|
||||
title: String,
|
||||
triggerAtMillis: Long,
|
||||
preNoticeAtMillis: Long,
|
||||
stationName: String?,
|
||||
stationUrl: String?,
|
||||
fallbackSound: String?,
|
||||
volume: Float
|
||||
) {
|
||||
Log.d(
|
||||
tag,
|
||||
"alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}"
|
||||
@@ -25,6 +34,10 @@ class AlarmScheduler(private val context: Context) {
|
||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, stationName)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, stationUrl)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, fallbackSound)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, volume)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
@@ -64,12 +64,25 @@ class MainActivity : AudioServiceActivity() {
|
||||
val title = call.argument<String>("title") ?: "PluriWave"
|
||||
val triggerAtMillis = call.argument<Long>("triggerAtMillis")
|
||||
val preNoticeAtMillis = call.argument<Long>("preNoticeAtMillis") ?: 0L
|
||||
val stationName = call.argument<String>("stationName")
|
||||
val stationUrl = call.argument<String>("stationUrl")
|
||||
val fallbackSound = call.argument<String>("fallbackSound")
|
||||
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f
|
||||
Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
|
||||
if (id == null || triggerAtMillis == null) {
|
||||
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
|
||||
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
|
||||
} else {
|
||||
alarmScheduler.scheduleAlarm(id, title, triggerAtMillis, preNoticeAtMillis)
|
||||
alarmScheduler.scheduleAlarm(
|
||||
id,
|
||||
title,
|
||||
triggerAtMillis,
|
||||
preNoticeAtMillis,
|
||||
stationName,
|
||||
stationUrl,
|
||||
fallbackSound,
|
||||
volume
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +92,7 @@ class MainActivity : AudioServiceActivity() {
|
||||
if (id == null) {
|
||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
||||
} else {
|
||||
PluriWaveAlarmService.stop(this, id)
|
||||
alarmScheduler.cancelAlarm(id)
|
||||
result.success(null)
|
||||
}
|
||||
@@ -89,10 +103,21 @@ class MainActivity : AudioServiceActivity() {
|
||||
if (id == null) {
|
||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
||||
} else {
|
||||
PluriWaveAlarmService.stop(this, id)
|
||||
alarmScheduler.dismissFireNotification(id)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
"stopNativeAlarmSound" -> {
|
||||
val id = call.argument<String>("id")
|
||||
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
|
||||
if (id == null) {
|
||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
||||
} else {
|
||||
PluriWaveAlarmService.stop(this, id)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
"diagnostics" -> {
|
||||
Log.d(tag, "alarm.channel diagnostics")
|
||||
result.success(
|
||||
|
||||
@@ -22,6 +22,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_FIRE -> {
|
||||
PluriWaveAlarmService.start(context, intent)
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
@@ -187,6 +188,10 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
const val EXTRA_ALARM_ID = "alarmId"
|
||||
const val EXTRA_ALARM_TITLE = "alarmTitle"
|
||||
const val EXTRA_ALARM_ACTION = "alarmAction"
|
||||
const val EXTRA_STATION_NAME = "stationName"
|
||||
const val EXTRA_STATION_URL = "stationUrl"
|
||||
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
|
||||
const val EXTRA_VOLUME = "volume"
|
||||
|
||||
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
|
||||
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
|
||||
class PluriWaveAlarmService : Service() {
|
||||
private var player: MediaPlayer? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var activeAlarmId: String? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action
|
||||
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
|
||||
|
||||
when (action) {
|
||||
ACTION_STOP -> {
|
||||
stopAlarm(requestedId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
|
||||
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startAlarm(intent: Intent?) {
|
||||
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
|
||||
if (activeAlarmId != null) {
|
||||
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
|
||||
return
|
||||
}
|
||||
activeAlarmId = alarmId
|
||||
|
||||
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
|
||||
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
|
||||
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
|
||||
|
||||
acquireWakeLock()
|
||||
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title))
|
||||
startAudio(alarmId, fallbackSound, volume)
|
||||
}
|
||||
|
||||
private fun startAudio(alarmId: String, fallbackSound: String?, volume: Float) {
|
||||
player?.release()
|
||||
player = null
|
||||
|
||||
val source = fallbackAssetPath(fallbackSound)
|
||||
try {
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
)
|
||||
isLooping = true
|
||||
setVolume(volume, volume)
|
||||
setFallbackAssetDataSource(this, fallbackSound)
|
||||
setOnPreparedListener {
|
||||
it.start()
|
||||
Log.d(TAG, "alarm.service audio started id=$alarmId source=$source")
|
||||
}
|
||||
setOnErrorListener { mp, what, extra ->
|
||||
Log.e(TAG, "alarm.service audio error id=$alarmId what=$what extra=$extra source=$source")
|
||||
mp.reset()
|
||||
true
|
||||
}
|
||||
prepareAsync()
|
||||
}
|
||||
Log.d(TAG, "alarm.service audio preparing id=$alarmId source=$source")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service audio prepare failed id=$alarmId source=$source", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAlarm(alarmId: String?) {
|
||||
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
|
||||
try {
|
||||
player?.stop()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service stop player failed", error)
|
||||
}
|
||||
player?.release()
|
||||
player = null
|
||||
activeAlarmId = null
|
||||
releaseWakeLock()
|
||||
if (alarmId != null) {
|
||||
NotificationManagerCompat.from(this).cancel(
|
||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun buildNotification(alarmId: String, title: String) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||
.setContentTitle("Alarma PluriWave")
|
||||
.setContentText(title)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title), true)
|
||||
.setContentIntent(openAlarmPendingIntent(alarmId, title))
|
||||
.addAction(0, "Detener", stopPendingIntent(alarmId))
|
||||
.build()
|
||||
|
||||
private fun openAlarmPendingIntent(alarmId: String, title: String): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
requestCode(alarmId, 20),
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun stopPendingIntent(alarmId: String): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
requestCode(alarmId, 21),
|
||||
Intent(this, PluriWaveAlarmService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"PluriWave:AlarmWakeLock"
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(10 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
try {
|
||||
if (wakeLock?.isHeld == true) wakeLock?.release()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service wakeLock release failed", error)
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
|
||||
val path = fallbackAssetPath(sound)
|
||||
try {
|
||||
val descriptor = assets.openFd(path)
|
||||
mediaPlayer.setDataSource(
|
||||
descriptor.fileDescriptor,
|
||||
descriptor.startOffset,
|
||||
descriptor.length
|
||||
)
|
||||
descriptor.close()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
|
||||
val cached = File(cacheDir, path.substringAfterLast('/'))
|
||||
assets.open(path).use { input ->
|
||||
cached.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
mediaPlayer.setDataSource(cached.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackAssetPath(sound: String?): String {
|
||||
val fileName = when (sound) {
|
||||
"campanaSuave" -> "alarm_campana_suave.wav"
|
||||
"pulsoDigital" -> "alarm_pulso_digital.wav"
|
||||
else -> "alarm_amanecer.wav"
|
||||
}
|
||||
return "flutter_assets/assets/audio/$fileName"
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopAlarm(activeAlarmId)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PluriWave"
|
||||
private const val CHANNEL_ID = "pluriwave_alarm_native"
|
||||
private const val NOTIFICATION_ID = 92841
|
||||
private const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
|
||||
|
||||
fun start(context: Context, source: Intent) {
|
||||
ensureChannel(context)
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||
putExtras(source)
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Log.d(TAG, "alarm.service start requested")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service start failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context, alarmId: String) {
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
}
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Alarma musical",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Sonido de alarma musical con pantalla apagada"
|
||||
enableVibration(true)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Arquitectura de alarmas con pantalla apagada
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
El flujo anterior hacía que Android recibiese la alarma con `AlarmManager`, pero el sonido real dependía de que se abriese `MainActivity` y de que Flutter llegase a pintar `PantallaAlarmaSonando`. Con pantalla apagada, Doze o restricciones del fabricante, ese arranque de UI puede retrasarse hasta que el usuario enciende la pantalla.
|
||||
|
||||
## Decisión
|
||||
|
||||
La alarma debe sonar desde Android nativo en cuanto llega `ACTION_FIRE`. Flutter pasa a ser la interfaz de control para detener, posponer y hacer handoff a la radio de la app, pero no el único origen del sonido.
|
||||
|
||||
## Flujo recomendado
|
||||
|
||||
1. `AlarmScheduler` programa la alarma con `setAlarmClock` y fallback exact/inexact.
|
||||
2. `PluriWaveAlarmReceiver` recibe `ACTION_FIRE`.
|
||||
3. El receiver arranca `PluriWaveAlarmService` como foreground service.
|
||||
4. El servicio toma un `PARTIAL_WAKE_LOCK`, muestra notificación foreground y reproduce audio con `USAGE_ALARM`.
|
||||
5. La UI Flutter se abre por full-screen intent si Android lo permite.
|
||||
6. Al detener/posponer desde Flutter, se manda comando nativo para parar el servicio.
|
||||
|
||||
## Referencias
|
||||
|
||||
- Android alarms: https://developer.android.com/develop/background-work/services/alarms
|
||||
- Foreground service restrictions: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start
|
||||
- AOSP DeskClock AlarmService: https://android.googlesource.com/platform/packages/apps/DeskClock/+/ac260c0096605526f772af7eec73d6a51dc6de32/src/com/android/deskclock/alarms/AlarmService.java
|
||||
|
||||
## Notas
|
||||
|
||||
- El audio local interno es el fallback más fiable para pantalla apagada.
|
||||
- La radio remota puede fallar por red, DNS, TLS o timeout; por eso debe existir fallback interno.
|
||||
- Si un fabricante bloquea incluso servicios arrancados desde alarma, habrá que guiar al usuario con permisos de batería/autostart.
|
||||
+90
-88
@@ -214,9 +214,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).skipCurrentAlarmExecution(
|
||||
alarma.nombre,
|
||||
),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).skipCurrentAlarmExecution(alarma.nombre),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -249,6 +249,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
_alarmaSonandoId = alarma.id;
|
||||
|
||||
try {
|
||||
await alarmas.android.detenerSonidoNativo(alarma.id);
|
||||
await _prearrancarAudioAlarma(alarma);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
@@ -286,89 +287,96 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) => Consumer<EstadoRadio>(
|
||||
builder: (ctx, estado, _) => SafeArea(
|
||||
child: Padding(
|
||||
padding: PluriLayout.sheetPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(ctx).sleepTimer,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: PluriLayout.sectionGap),
|
||||
Text(
|
||||
AppLocalizations.of(ctx).sleepTimerDescription,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: PluriLayout.panelGap),
|
||||
if (estado.timer.activo)
|
||||
StreamBuilder<Duration>(
|
||||
stream: estado.timer.tiempoRestanteStream,
|
||||
builder: (ctx, snap) {
|
||||
final restante =
|
||||
snap.data ?? estado.timer.tiempoRestante;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
builder:
|
||||
(ctx, estado, _) => SafeArea(
|
||||
child: Padding(
|
||||
padding: PluriLayout.sheetPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(ctx).sleepTimer,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: PluriLayout.sectionGap),
|
||||
Text(
|
||||
AppLocalizations.of(ctx).sleepTimerDescription,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: PluriLayout.panelGap),
|
||||
if (estado.timer.activo)
|
||||
StreamBuilder<Duration>(
|
||||
stream: estado.timer.tiempoRestanteStream,
|
||||
builder: (ctx, snap) {
|
||||
final restante =
|
||||
snap.data ?? estado.timer.tiempoRestante;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
_formatearDuracionTimer(restante),
|
||||
style:
|
||||
Theme.of(ctx).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: PluriLayout.compactGap,
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
estado.cancelarTimer();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(ctx).cancelTimer,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: PluriLayout.compactGap,
|
||||
runSpacing: PluriLayout.compactGap,
|
||||
children: [
|
||||
Text(
|
||||
_formatearDuracionTimer(restante),
|
||||
style: Theme.of(ctx).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: PluriLayout.compactGap),
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
estado.cancelarTimer();
|
||||
for (final segundos
|
||||
in estado.timerSuenoPresetsSegundos)
|
||||
ActionChip(
|
||||
label: Text(
|
||||
_formatearDuracionTimer(
|
||||
Duration(seconds: segundos),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(
|
||||
Icons.tune_rounded,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
AppLocalizations.of(ctx).optionOther,
|
||||
),
|
||||
onPressed: () async {
|
||||
final duracion =
|
||||
await _pedirDuracionPersonalizada(ctx);
|
||||
if (duracion == null || !ctx.mounted) return;
|
||||
estado.iniciarTimerDuracion(duracion);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(ctx).cancelTimer,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: PluriLayout.compactGap,
|
||||
runSpacing: PluriLayout.compactGap,
|
||||
children: [
|
||||
for (final segundos
|
||||
in estado.timerSuenoPresetsSegundos)
|
||||
ActionChip(
|
||||
label: Text(
|
||||
_formatearDuracionTimer(
|
||||
Duration(seconds: segundos),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.tune_rounded, size: 18),
|
||||
label: Text(
|
||||
AppLocalizations.of(ctx).optionOther,
|
||||
),
|
||||
onPressed: () async {
|
||||
final duracion =
|
||||
await _pedirDuracionPersonalizada(ctx);
|
||||
if (duracion == null || !ctx.mounted) return;
|
||||
estado.iniciarTimerDuracion(duracion);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -429,9 +437,7 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
|
||||
if (duracion <= Duration.zero) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).durationGreaterThanZero,
|
||||
),
|
||||
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -484,18 +490,14 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
|
||||
const SizedBox(height: PluriLayout.compactGap),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
AppLocalizations.of(context).saveQuickAccess,
|
||||
),
|
||||
title: Text(AppLocalizations.of(context).saveQuickAccess),
|
||||
value: _guardarPreset,
|
||||
onChanged: (value) => setState(() => _guardarPreset = value),
|
||||
),
|
||||
const SizedBox(height: PluriLayout.sectionGap),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.bedtime_rounded),
|
||||
label: Text(
|
||||
AppLocalizations.of(context).startTimer,
|
||||
),
|
||||
label: Text(AppLocalizations.of(context).startTimer),
|
||||
onPressed: _confirmar,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -80,6 +80,10 @@ class ServicioAlarmasAndroid {
|
||||
'triggerAtMillis': proxima.millisecondsSinceEpoch,
|
||||
'preNoticeAtMillis':
|
||||
proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch,
|
||||
'stationName': alarma.emisora?.nombre,
|
||||
'stationUrl': alarma.emisora?.url,
|
||||
'fallbackSound': alarma.sonidoInterno.name,
|
||||
'volume': alarma.volumen,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,6 +93,9 @@ class ServicioAlarmasAndroid {
|
||||
Future<void> ocultarNotificacionAlarma(String alarmaId) =>
|
||||
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
|
||||
|
||||
Future<void> detenerSonidoNativo(String alarmaId) =>
|
||||
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
|
||||
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
|
||||
debugPrint('[PluriWave][alarmas] diagnostico android');
|
||||
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||
|
||||
Reference in New Issue
Block a user