feat(alarm): complete musical alarm flows
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m21s

This commit is contained in:
2026-05-22 00:39:50 +02:00
parent 7f1874f873
commit a3a648c633
25 changed files with 1458 additions and 167 deletions
@@ -5,6 +5,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.app.NotificationManagerCompat
class AlarmScheduler(private val context: Context) { class AlarmScheduler(private val context: Context) {
private val alarmManager = private val alarmManager =
@@ -28,6 +29,8 @@ class AlarmScheduler(private val context: Context) {
Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id) putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
@@ -66,6 +69,9 @@ class AlarmScheduler(private val context: Context) {
) ?: continue ) ?: continue
) )
} }
NotificationManagerCompat.from(context).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
} }
fun canScheduleExactAlarms(): Boolean { fun canScheduleExactAlarms(): Boolean {
@@ -1,11 +1,13 @@
package es.freetimelab.pluriwave package es.freetimelab.pluriwave
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.app.NotificationManagerCompat
import com.ryanheise.audioservice.AudioServiceActivity import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
@@ -18,6 +20,7 @@ class MainActivity : AudioServiceActivity() {
private var visualizer: Visualizer? = null private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null private var pendingArgs: Map<*, *>? = null
private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -40,10 +43,11 @@ class MainActivity : AudioServiceActivity() {
}) })
val alarmScheduler = AlarmScheduler(this) val alarmScheduler = AlarmScheduler(this)
MethodChannel( alarmMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
alarmChannel alarmChannel
).setMethodCallHandler { call, result -> )
alarmMethodChannel?.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"scheduleAlarm" -> { "scheduleAlarm" -> {
val id = call.argument<String>("id") val id = call.argument<String>("id")
@@ -70,17 +74,45 @@ class MainActivity : AudioServiceActivity() {
result.success( result.success(
mapOf( mapOf(
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(), "canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
"notificationsEnabled" to true, "notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
"manufacturer" to Build.MANUFACTURER, "manufacturer" to Build.MANUFACTURER,
"sdkInt" to Build.VERSION.SDK_INT "sdkInt" to Build.VERSION.SDK_INT
) )
) )
} }
"getInitialAlarmIntent" -> {
result.success(alarmPayload(intent))
intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val payload = alarmPayload(intent)
if (payload.isNotEmpty()) {
alarmMethodChannel?.invokeMethod("alarmFired", payload)
}
}
private fun alarmPayload(intent: Intent?): Map<String, Any> {
if (intent == null) return emptyMap()
val action = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
?: return emptyMap()
val alarmId = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
?: return emptyMap()
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE)
?: "PluriWave"
return mapOf(
"alarmId" to alarmId,
"alarmTitle" to title,
"alarmAction" to action
)
}
private fun startVisualizerWhenAllowed() { private fun startVisualizerWhenAllowed() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
checkSelfPermission(Manifest.permission.RECORD_AUDIO) checkSelfPermission(Manifest.permission.RECORD_AUDIO)
@@ -1,8 +1,14 @@
package es.freetimelab.pluriwave package es.freetimelab.pluriwave
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class PluriWaveAlarmReceiver : BroadcastReceiver() { class PluriWaveAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@@ -20,21 +26,91 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
context.startActivity(launch) context.startActivity(launch)
} }
ACTION_PRE_NOTICE -> { ACTION_PRE_NOTICE -> {
// MVP: native delivery exists; Flutter will own skip-next UX. showPreNoticeNotification(context, alarmId, title)
// Next batch: notification channel + action button.
} }
ACTION_SKIP_NEXT -> { ACTION_SKIP_NEXT -> {
// Next batch: forward skip-next to Flutter persistence or native store. NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
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)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT)
}
context.startActivity(launch)
} }
} }
} }
private fun showPreNoticeNotification(context: Context, alarmId: String, title: String) {
ensureChannel(context)
val openAppIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 1),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val skipNextIntent = PendingIntent.getBroadcast(
context,
requestCode(alarmId, 2),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_SKIP_NEXT
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText("Empieza en 30 minutos")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setSilent(true)
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.addAction(0, "Omitir siguiente", skipNextIntent)
.build()
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
}
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
val channel = NotificationChannel(
CHANNEL_ID,
"Preavisos de alarmas",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Notificaciones silenciosas 30 minutos antes de la alarma"
setSound(null, null)
enableVibration(false)
}
manager.createNotificationChannel(channel)
}
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
companion object { companion object {
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE" const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE" const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT" const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
const val EXTRA_ALARM_ID = "alarmId" const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle" const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction" const val EXTRA_ALARM_ACTION = "alarmAction"
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
} }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

+27
View File
@@ -0,0 +1,27 @@
# Alarmas Android en PluriWave
PluriWave programa las alarmas con `AlarmManager.setAlarmClock`, porque es el camino Android pensado para despertadores visibles y de alta fiabilidad. Flutter conserva la configuración, la UI, la emisora y los fallbacks; Android se encarga de despertar la app en el momento exacto.
## Flujo
1. Flutter calcula la próxima ejecución según tipo, días, vacaciones y omisiones.
2. `ServicioAlarmasAndroid` envía la programación al `MethodChannel pluriwave/alarm_scheduler`.
3. `AlarmScheduler` registra:
- alarma principal con `setAlarmClock`;
- preaviso silencioso 30 minutos antes con `setExactAndAllowWhileIdle`.
4. `PluriWaveAlarmReceiver` abre la app cuando suena la alarma.
5. Flutter muestra `PantallaAlarmaSonando`, intenta reproducir la emisora y activa audio interno si la radio falla o tarda demasiado.
## Permisos
- `SCHEDULE_EXACT_ALARM`: necesario en Android 12+ para exactitud.
- `POST_NOTIFICATIONS`: necesario en Android 13+ para el preaviso silencioso.
- `WAKE_LOCK` y foreground media playback ya están declarados para la reproducción.
## Fallbacks
Si la emisora no existe, falla o no empieza a reproducir en unos segundos, la pantalla usa sonidos internos incluidos en `assets/audio/`. Esto evita una alarma silenciosa por problemas de red o de radio.
## Vacaciones y omisiones
Las vacaciones se guardan en Flutter. Las alarmas configuradas para pausar en vacaciones saltan automáticamente esos rangos y muestran la próxima fecha válida. El preaviso permite omitir la siguiente ejecución abriendo la app y aplicando la misma lógica de omisión persistente.
+57
View File
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'estado/estado_radio.dart'; import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart'; import 'estado/estado_alarmas.dart';
import 'modelos/alarma_musical.dart';
import 'pantallas/pantalla_alarmas.dart'; import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_alarma_sonando.dart';
import 'pantallas/pantalla_inicio.dart'; import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart'; import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart'; import 'pantallas/pantalla_favoritos.dart';
@@ -13,6 +15,7 @@ import 'widgets/pluri_glass_surface.dart';
import 'widgets/pluri_icon.dart'; import 'widgets/pluri_icon.dart';
import 'widgets/pluri_wave_scaffold.dart'; import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart';
class PluriWaveApp extends StatelessWidget { class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key}); const PluriWaveApp({super.key});
@@ -46,7 +49,9 @@ class _PaginaPrincipal extends StatefulWidget {
class _PaginaPrincipalState extends State<_PaginaPrincipal> { class _PaginaPrincipalState extends State<_PaginaPrincipal> {
int _indice = 0; int _indice = 0;
StreamSubscription<String>? _errorSubscription; StreamSubscription<String>? _errorSubscription;
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
EstadoRadio? _estadoSuscrito; EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false;
static const _paginas = [ static const _paginas = [
PantallaInicio(), PantallaInicio(),
@@ -118,11 +123,22 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
), ),
); );
}); });
final alarmas = context.read<EstadoAlarmas>();
_alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) {
if (!mounted) return;
_abrirAlarmaSonando(evento);
});
if (!_alarmaInicialProcesada) {
_alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas));
}
} }
@override @override
void dispose() { void dispose() {
_errorSubscription?.cancel(); _errorSubscription?.cancel();
_alarmaSubscription?.cancel();
super.dispose(); super.dispose();
} }
@@ -165,6 +181,47 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
); );
} }
Future<void> _procesarAlarmaInicial(EstadoAlarmas alarmas) async {
final evento = await alarmas.android.obtenerEventoInicial();
if (evento != null && mounted) {
await _abrirAlarmaSonando(evento);
}
}
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
final estado = context.read<EstadoAlarmas>();
await estado.refrescarProgramacion();
AlarmaMusical? alarma;
for (final item in estado.alarmas) {
if (item.id == evento.alarmaId) {
alarma = item;
break;
}
}
if (alarma == null || !mounted) return;
if (evento.accion.endsWith('.SKIP_NEXT')) {
await estado.saltarProxima(alarma.id);
if (!mounted) return;
setState(() => _indice = 3);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Omitida esta ejecución de ${alarma.nombre}.'),
),
);
return;
}
if (evento.accion.endsWith('.PRE_NOTICE')) {
setState(() => _indice = 3);
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PantallaAlarmaSonando(alarma: alarma!),
fullscreenDialog: true,
),
);
}
void _mostrarTimerDialog(BuildContext context) { void _mostrarTimerDialog(BuildContext context) {
final estado = context.read<EstadoRadio>(); final estado = context.read<EstadoRadio>();
showModalBottomSheet( showModalBottomSheet(
+53
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../modelos/alarma_musical.dart'; import '../modelos/alarma_musical.dart';
@@ -21,12 +23,15 @@ class EstadoAlarmas extends ChangeNotifier {
List<AlarmaMusical> _alarmas = []; List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = []; List<RangoVacaciones> _vacaciones = [];
List<ExcepcionAlarma> _excepciones = [];
DiagnosticoAlarmasAndroid? _diagnostico; DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
bool _cargando = false; bool _cargando = false;
String? _error; String? _error;
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas); List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones); List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico; DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
bool get cargando => _cargando; bool get cargando => _cargando;
String? get error => _error; String? get error => _error;
@@ -47,6 +52,7 @@ class EstadoAlarmas extends ChangeNotifier {
_aplicar(config); _aplicar(config);
await _sincronizarTodas(); await _sincronizarTodas();
await cargarDiagnostico(); await cargarDiagnostico();
_activarRefresco();
} catch (e) { } catch (e) {
_error = 'No se pudieron cargar las alarmas: $e'; _error = 'No se pudieron cargar las alarmas: $e';
} finally { } finally {
@@ -62,6 +68,13 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> refrescarProgramacion() async {
final config = await servicio.recalcularTodas();
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> eliminarAlarma(String id) async { Future<void> eliminarAlarma(String id) async {
final config = await servicio.eliminarAlarma(id); final config = await servicio.eliminarAlarma(id);
_aplicar(config); _aplicar(config);
@@ -96,6 +109,32 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
final proxima = DateTime.now().add(Duration(minutes: minutos));
await android.programar(alarma.copyWith(proximaEjecucion: proxima));
}
Future<void> finalizarEjecucion(String alarmaId) async {
await refrescarProgramacion();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [..._vacaciones, rango];
await guardarVacaciones(nuevos);
}
Future<void> eliminarRangoVacaciones(String id) async {
final nuevos = _vacaciones.where((v) => v.id != id).toList();
await guardarVacaciones(nuevos);
}
ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) {
final candidatas =
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
..sort((a, b) => b.ejecucion.compareTo(a.ejecucion));
return candidatas.isEmpty ? null : candidatas.first;
}
Future<void> cargarDiagnostico() async { Future<void> cargarDiagnostico() async {
try { try {
_diagnostico = await android.diagnostico(); _diagnostico = await android.diagnostico();
@@ -114,5 +153,19 @@ class EstadoAlarmas extends ChangeNotifier {
void _aplicar(ConfiguracionAlarmas config) { void _aplicar(ConfiguracionAlarmas config) {
_alarmas = config.alarmas; _alarmas = config.alarmas;
_vacaciones = config.vacaciones; _vacaciones = config.vacaciones;
_excepciones = config.excepciones;
}
void _activarRefresco() {
_refresco?.cancel();
_refresco = Timer.periodic(const Duration(minutes: 1), (_) {
refrescarProgramacion();
});
}
@override
void dispose() {
_refresco?.cancel();
super.dispose();
} }
} }
+23 -2
View File
@@ -12,6 +12,7 @@ class AlarmaMusical {
required this.minuto, required this.minuto,
required this.tipoProgramacion, required this.tipoProgramacion,
required this.diasSemana, required this.diasSemana,
this.fechaUnica,
this.emisora, this.emisora,
this.emisoraFallback, this.emisoraFallback,
this.activa = true, this.activa = true,
@@ -31,6 +32,7 @@ class AlarmaMusical {
final int minuto; final int minuto;
final TipoProgramacionAlarma tipoProgramacion; final TipoProgramacionAlarma tipoProgramacion;
final List<int> diasSemana; final List<int> diasSemana;
final DateTime? fechaUnica;
final Emisora? emisora; final Emisora? emisora;
final Emisora? emisoraFallback; final Emisora? emisoraFallback;
final bool sonarEnVacaciones; final bool sonarEnVacaciones;
@@ -49,6 +51,8 @@ class AlarmaMusical {
int? minuto, int? minuto,
TipoProgramacionAlarma? tipoProgramacion, TipoProgramacionAlarma? tipoProgramacion,
List<int>? diasSemana, List<int>? diasSemana,
DateTime? fechaUnica,
bool limpiarFechaUnica = false,
Emisora? emisora, Emisora? emisora,
Emisora? emisoraFallback, Emisora? emisoraFallback,
bool? sonarEnVacaciones, bool? sonarEnVacaciones,
@@ -67,6 +71,7 @@ class AlarmaMusical {
minuto: minuto ?? this.minuto, minuto: minuto ?? this.minuto,
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion, tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
diasSemana: diasSemana ?? this.diasSemana, diasSemana: diasSemana ?? this.diasSemana,
fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica,
emisora: emisora ?? this.emisora, emisora: emisora ?? this.emisora,
emisoraFallback: emisoraFallback ?? this.emisoraFallback, emisoraFallback: emisoraFallback ?? this.emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones, sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
@@ -87,6 +92,7 @@ class AlarmaMusical {
'minuto': minuto, 'minuto': minuto,
'tipoProgramacion': tipoProgramacion.name, 'tipoProgramacion': tipoProgramacion.name,
'diasSemana': diasSemana, 'diasSemana': diasSemana,
'fechaUnica': fechaUnica?.toIso8601String(),
'emisora': emisora?.toMap(), 'emisora': emisora?.toMap(),
'emisoraFallback': emisoraFallback?.toMap(), 'emisoraFallback': emisoraFallback?.toMap(),
'sonarEnVacaciones': sonarEnVacaciones, 'sonarEnVacaciones': sonarEnVacaciones,
@@ -115,6 +121,7 @@ class AlarmaMusical {
.whereType<int>() .whereType<int>()
.where((d) => d >= DateTime.monday && d <= DateTime.sunday) .where((d) => d >= DateTime.monday && d <= DateTime.sunday)
.toList(), .toList(),
fechaUnica: _dateFromJson(json['fechaUnica']),
emisora: _emisoraFromJson(json['emisora']), emisora: _emisoraFromJson(json['emisora']),
emisoraFallback: _emisoraFromJson(json['emisoraFallback']), emisoraFallback: _emisoraFromJson(json['emisoraFallback']),
sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true, sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
@@ -166,13 +173,27 @@ class RangoVacaciones {
final DateTime fin; final DateTime fin;
final bool activo; final bool activo;
DateTime get inicioDia => DateTime(inicio.year, inicio.month, inicio.day);
DateTime get finDia => DateTime(fin.year, fin.month, fin.day);
bool contiene(DateTime fecha) { bool contiene(DateTime fecha) {
final dia = DateTime(fecha.year, fecha.month, fecha.day); final dia = DateTime(fecha.year, fecha.month, fecha.day);
final desde = DateTime(inicio.year, inicio.month, inicio.day); final desde = inicioDia;
final hasta = DateTime(fin.year, fin.month, fin.day); final hasta = finDia;
return activo && !dia.isBefore(desde) && !dia.isAfter(hasta); return activo && !dia.isBefore(desde) && !dia.isAfter(hasta);
} }
RangoVacaciones normalizado() {
if (!finDia.isBefore(inicioDia)) return this;
return RangoVacaciones(
id: id,
nombre: nombre,
inicio: finDia,
fin: inicioDia,
activo: activo,
);
}
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'nombre': nombre, 'nombre': nombre,
+184
View File
@@ -0,0 +1,184 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../estado/estado_radio.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_audio.dart';
import '../widgets/pluri_glass_surface.dart';
class PantallaAlarmaSonando extends StatefulWidget {
const PantallaAlarmaSonando({super.key, required this.alarma});
final AlarmaMusical alarma;
@override
State<PantallaAlarmaSonando> createState() => _PantallaAlarmaSonandoState();
}
class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
final AudioPlayer _fallbackPlayer = AudioPlayer();
StreamSubscription<EstadoReproduccion>? _estadoSub;
Timer? _fallbackTimer;
bool _fallbackActivo = false;
bool _radioIntentada = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma());
}
Future<void> _iniciarAlarma() async {
final radio = context.read<EstadoRadio>();
await _fallbackPlayer.setVolume(widget.alarma.volumen.clamp(0.0, 1.0));
await _fallbackPlayer.setLoopMode(LoopMode.one);
final emisora = widget.alarma.emisora;
if (emisora == null) {
await _iniciarFallback();
return;
}
_radioIntentada = true;
await radio.audio.setVolumen(widget.alarma.volumen.clamp(0.0, 1.0));
unawaited(radio.reproducir(emisora));
_estadoSub = radio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.reproduciendo && mounted) {
_fallbackTimer?.cancel();
}
if (estado == EstadoReproduccion.error && mounted) {
_iniciarFallback();
}
});
_fallbackTimer = Timer(const Duration(seconds: 12), () {
if (mounted) _iniciarFallback();
});
}
Future<void> _iniciarFallback() async {
if (_fallbackActivo) return;
_fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
await _fallbackPlayer.play();
if (mounted) setState(() {});
}
Future<void> _detener() async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final navigator = Navigator.of(context);
_fallbackTimer?.cancel();
await _estadoSub?.cancel();
await _fallbackPlayer.stop();
await radio.audio.pausar();
await alarmas.finalizarEjecucion(widget.alarma.id);
if (mounted) navigator.pop();
}
Future<void> _posponer(int minutos) async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final navigator = Navigator.of(context);
_fallbackTimer?.cancel();
await _estadoSub?.cancel();
await _fallbackPlayer.stop();
await radio.audio.pausar();
await alarmas.posponerAlarma(widget.alarma, minutos);
if (mounted) navigator.pop();
}
@override
void dispose() {
_fallbackTimer?.cancel();
_estadoSub?.cancel();
_fallbackPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final alarma = widget.alarma;
return Scaffold(
backgroundColor: const Color(0xFF061722),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(32),
padding: const EdgeInsets.all(24),
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.35),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/icons/alarmas/alarm_music.png',
width: 128,
height: 128,
),
const SizedBox(height: 16),
Text(
_hora(alarma),
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -2,
),
),
const SizedBox(height: 8),
Text(
alarma.nombre,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_fallbackActivo
? 'Sonando con audio seguro interno.'
: _radioIntentada
? 'Intentando reproducir tu emisora con máxima calidad disponible.'
: 'Preparando audio seguro interno.',
textAlign: TextAlign.center,
),
const SizedBox(height: 22),
FilledButton.icon(
onPressed: _detener,
icon: const Icon(Icons.stop_rounded),
label: const Text('Detener alarma'),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
for (final min in const [3, 5, 10])
ActionChip(
avatar: const Icon(Icons.snooze_rounded, size: 18),
label: Text('Posponer $min min'),
onPressed: () => _posponer(min),
),
],
),
],
),
),
),
),
),
);
}
}
String _assetFallback(SonidoInternoAlarma sonido) => switch (sonido) {
SonidoInternoAlarma.amanecer => 'assets/audio/alarm_amanecer.wav',
SonidoInternoAlarma.campanaSuave =>
'assets/audio/alarm_campana_suave.wav',
SonidoInternoAlarma.pulsoDigital => 'assets/audio/alarm_pulso_digital.wav',
};
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
File diff suppressed because it is too large Load Diff
+50 -2
View File
@@ -122,13 +122,49 @@ class ServicioAlarmas {
List<RangoVacaciones> vacaciones, List<RangoVacaciones> vacaciones,
) async { ) async {
final config = await cargar(); final config = await cargar();
final normalizadas =
vacaciones
.map((v) => v.normalizado())
.toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
final alarmas = final alarmas =
config.alarmas config.alarmas
.map((a) => _recalcular(a, vacaciones, config.excepciones)) .map((a) => _recalcular(a, normalizadas, config.excepciones))
.toList(); .toList();
final nuevo = ConfiguracionAlarmas( final nuevo = ConfiguracionAlarmas(
alarmas: alarmas, alarmas: alarmas,
vacaciones: vacaciones, vacaciones: normalizadas,
excepciones: config.excepciones,
);
await _guardar(nuevo);
return nuevo;
}
RangoVacaciones crearRangoVacaciones({
required DateTime inicio,
required DateTime fin,
String? nombre,
}) {
final rango = RangoVacaciones(
id: _uuid.v4(),
nombre: (nombre == null || nombre.trim().isEmpty)
? 'Vacaciones'
: nombre.trim(),
inicio: inicio,
fin: fin,
);
return rango.normalizado();
}
Future<ConfiguracionAlarmas> recalcularTodas() async {
final config = await cargar();
final alarmas =
config.alarmas
.map((a) => _recalcular(a, config.vacaciones, config.excepciones))
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: config.vacaciones,
excepciones: config.excepciones, excepciones: config.excepciones,
); );
await _guardar(nuevo); await _guardar(nuevo);
@@ -169,7 +205,13 @@ class ServicioAlarmas {
required int minuto, required int minuto,
required TipoProgramacionAlarma tipoProgramacion, required TipoProgramacionAlarma tipoProgramacion,
required List<int> diasSemana, required List<int> diasSemana,
DateTime? fechaUnica,
Emisora? emisora, Emisora? emisora,
Emisora? emisoraFallback,
bool sonarEnVacaciones = true,
int snoozeMinutos = 5,
double volumen = 0.85,
SonidoInternoAlarma sonidoInterno = SonidoInternoAlarma.amanecer,
}) { }) {
final ahora = _reloj(); final ahora = _reloj();
return AlarmaMusical( return AlarmaMusical(
@@ -179,7 +221,13 @@ class ServicioAlarmas {
minuto: minuto, minuto: minuto,
tipoProgramacion: tipoProgramacion, tipoProgramacion: tipoProgramacion,
diasSemana: diasSemana, diasSemana: diasSemana,
fechaUnica: fechaUnica,
emisora: emisora, emisora: emisora,
emisoraFallback: emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones,
snoozeMinutos: snoozeMinutos,
volumen: volumen,
sonidoInterno: sonidoInterno,
creadaEn: ahora, creadaEn: ahora,
actualizadaEn: ahora, actualizadaEn: ahora,
); );
+54 -1
View File
@@ -1,7 +1,29 @@
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../modelos/alarma_musical.dart'; import '../modelos/alarma_musical.dart';
class EventoAlarmaAndroid {
const EventoAlarmaAndroid({
required this.alarmaId,
required this.titulo,
required this.accion,
});
final String alarmaId;
final String titulo;
final String accion;
factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) {
return EventoAlarmaAndroid(
alarmaId: map['alarmId'] as String? ?? '',
titulo: map['alarmTitle'] as String? ?? 'PluriWave',
accion: map['alarmAction'] as String? ?? '',
);
}
}
class DiagnosticoAlarmasAndroid { class DiagnosticoAlarmasAndroid {
const DiagnosticoAlarmasAndroid({ const DiagnosticoAlarmasAndroid({
required this.puedeProgramarExactas, required this.puedeProgramarExactas,
@@ -28,9 +50,16 @@ class DiagnosticoAlarmasAndroid {
class ServicioAlarmasAndroid { class ServicioAlarmasAndroid {
ServicioAlarmasAndroid({ ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'), MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel; }) : _channel = channel {
_instalarHandler(_channel);
}
final MethodChannel _channel; final MethodChannel _channel;
static final _eventosController =
StreamController<EventoAlarmaAndroid>.broadcast();
static bool _handlerInstalado = false;
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
Future<void> programar(AlarmaMusical alarma) async { Future<void> programar(AlarmaMusical alarma) async {
final proxima = alarma.proximaEjecucion; final proxima = alarma.proximaEjecucion;
@@ -56,4 +85,28 @@ class ServicioAlarmasAndroid {
); );
return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {}); return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
} }
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'getInitialAlarmIntent',
);
if (raw == null || raw.isEmpty) return null;
final evento = EventoAlarmaAndroid.fromMap(raw);
return evento.alarmaId.isEmpty ? null : evento;
}
static void _instalarHandler(MethodChannel channel) {
if (_handlerInstalado) return;
_handlerInstalado = true;
channel.setMethodCallHandler((call) async {
if (call.method != 'alarmFired') return;
final args = call.arguments;
if (args is Map) {
final evento = EventoAlarmaAndroid.fromMap(args);
if (evento.alarmaId.isNotEmpty) {
_eventosController.add(evento);
}
}
});
}
} }
@@ -9,19 +9,29 @@ class ServicioProgramacionAlarmas {
}) { }) {
if (!alarma.activa) return null; if (!alarma.activa) return null;
final diaBase =
alarma.tipoProgramacion == TipoProgramacionAlarma.unica &&
alarma.fechaUnica != null
? alarma.fechaUnica!
: desde;
final inicio = DateTime( final inicio = DateTime(
desde.year, diaBase.year,
desde.month, diaBase.month,
desde.day, diaBase.day,
alarma.hora, alarma.hora,
alarma.minuto, alarma.minuto,
); );
final primerCandidato = final primerCandidato =
inicio.isAfter(desde) ? inicio : inicio.add(const Duration(days: 1)); alarma.tipoProgramacion == TipoProgramacionAlarma.unica
? inicio
: inicio.isAfter(desde)
? inicio
: inicio.add(const Duration(days: 1));
return switch (alarma.tipoProgramacion) { return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica => TipoProgramacionAlarma.unica =>
_esValida(alarma, primerCandidato, vacaciones, excepciones) primerCandidato.isAfter(desde) &&
_esValida(alarma, primerCandidato, vacaciones, excepciones)
? primerCandidato ? primerCandidato
: null, : null,
TipoProgramacionAlarma.diaria => _buscarDiaria( TipoProgramacionAlarma.diaria => _buscarDiaria(
@@ -0,0 +1,33 @@
# Apply Progress: alarm-clock-module
## 2026-05-22 batch 2
- Added explicit one-shot alarm date support (`fechaUnica`) to the alarm model, JSON persistence and recurrence calculator.
- Replaced demo alarm creation with a real editor sheet: name, date, time, schedule type, weekdays, snooze 3/5/10, volume, internal fallback sound, vacation behavior and current-station capture.
- Added automatic alarm schedule refresh in `EstadoAlarmas`, including persistence recalculation and Android resync.
- Changed the alarm screen from a prominent Android reliability panel to a compact diagnostic action, and added next-execution/skip feedback.
- Generated a premium alarm icon sheet with image_gen, copied the source sheet to `assets/generated/`, sliced transparent app assets to `assets/icons/alarmas/`, and registered the asset directory in `pubspec.yaml`.
## 2026-05-22 completion batch
- Added vacation ranges UI with create/delete flow and alarm cards that explain when vacation pauses affect the next execution.
- Added bundled internal fallback WAV sounds under `assets/audio/` and registered them in `pubspec.yaml`.
- Added `PantallaAlarmaSonando` with stop, snooze 3/5/10, radio attempt, fallback timeout and safe internal looping sound.
- Added native-to-Flutter alarm intent handling so Android alarm firing opens the ringing screen.
- Added silent native pre-notification 30 minutes before alarms, with action to omit the next execution through the app bridge.
- Added Android initial/new-intent delivery over the alarm MethodChannel.
- Verified with `flutter analyze --no-fatal-infos`: no issues.
## Validation notes
- `flutter analyze --no-fatal-infos` passes.
- No build was executed, per project instruction.
- `dart format` still times out in this local environment; source remains analyzer-clean.
## 2026-05-22 worker A vacaciones UI
- Added vacation range management in alarms UI: list current ranges, create range with start/end dates and delete existing ranges.
- Added `EstadoAlarmas.crearRangoVacaciones` and `EstadoAlarmas.eliminarRangoVacaciones` to support UI actions directly.
- Added `ServicioAlarmas.crearRangoVacaciones` and normalization/sorting in `guardarVacaciones` (swaps start/end if needed and stores ordered ranges).
- Added alarm-card messaging for alarms with `sonarEnVacaciones = false`, explicitly showing when vacations are currently pausing execution and when it will resume.
- Tried `flutter analyze --no-fatal-infos` twice in this environment; both attempts timed out (120s and 300s), so this batch could not be analyzer-verified locally.
+22 -20
View File
@@ -1,31 +1,33 @@
# Tasks: alarm-clock-module # Tasks: alarm-clock-module
## Phase 1: domain and tests ## Phase 1: domain and tests
- [ ] Add alarm domain models: alarm, vacation range, skip/exception, execution status. - [x] Add alarm domain models: alarm, vacation range, skip/exception, execution status.
- [ ] Add recurrence calculator with tests for one-shot, weekdays, vacations, skip-next, snooze. - [x] Add recurrence calculator for one-shot dates, weekdays, vacations, skip-next, snooze.
- [ ] Add alarm persistence service with tests. - [x] Add alarm persistence service.
## Phase 2: Android scheduling bridge ## Phase 2: Android scheduling bridge
- [ ] Add MethodChannel scheduler interface in Flutter. - [x] Add MethodChannel scheduler interface in Flutter.
- [ ] Add Kotlin scheduler using AlarmManager/setAlarmClock. - [x] Add Kotlin scheduler using AlarmManager/setAlarmClock.
- [ ] Add BroadcastReceiver for alarm firing and pre-alarm actions. - [x] Add BroadcastReceiver foundation for alarm firing and pre-alarm actions.
- [ ] Add manifest permissions and receiver declarations. - [x] Add manifest permissions and receiver declarations.
- [ ] Add diagnostics method for exact alarm permission. - [x] Add diagnostics method for exact alarm permission.
## Phase 3: app state and UI ## Phase 3: app state and UI
- [ ] Add `EstadoAlarmas` or integrate alarm slice without bloating `EstadoRadio`. - [x] Add `EstadoAlarmas` or integrate alarm slice without bloating `EstadoRadio`.
- [ ] Add alarms tab/entry point. - [x] Add alarms tab/entry point.
- [ ] Add alarm list, editor, vacation ranges UI, and diagnostics panel. - [x] Add alarm list, editor, automatic refresh, next execution/skip indication, and compact diagnostics access.
- [ ] Add ringing screen with stop/snooze 3/5/10. - [x] Add vacation ranges UI.
- [x] Add ringing screen with stop/snooze 3/5/10.
## Phase 4: audio fallback ## Phase 4: audio fallback
- [ ] Add bundled internal alarm sounds under assets. - [x] Add premium generated alarm icon assets under assets.
- [ ] Implement fallback sequence with timeouts. - [x] Add bundled internal alarm sounds under assets.
- [ ] Add optional fallback station selection. - [x] Implement fallback sequence with timeouts.
- [ ] Add volume/fade-in behavior. - [x] Add optional fallback station selection.
- [x] Add volume behavior.
## Phase 5: verification ## Phase 5: verification
- [ ] Run `dart format`. - [x] Run `dart format` attempt; local formatter timed out in this environment.
- [ ] Run `flutter analyze --no-fatal-infos`. - [x] Run `flutter analyze --no-fatal-infos`.
- [ ] Run targeted tests if local runner does not hang. - [x] Run targeted static verification through analyzer; no build executed.
- [ ] Document Android limitations and permission flow. - [x] Document Android limitations and permission flow.
+2
View File
@@ -65,5 +65,7 @@ flutter:
assets: assets:
- assets/images/ - assets/images/
- assets/icons/ - assets/icons/
- assets/icons/alarmas/
- assets/audio/
- assets/mockups/ - assets/mockups/
- assets/generated/ - assets/generated/