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.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat
class AlarmScheduler(private val context: Context) {
private val alarmManager =
@@ -28,6 +29,8 @@ class AlarmScheduler(private val context: Context) {
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
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
)
@@ -66,6 +69,9 @@ class AlarmScheduler(private val context: Context) {
) ?: continue
)
}
NotificationManagerCompat.from(context).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
}
fun canScheduleExactAlarms(): Boolean {
@@ -1,11 +1,13 @@
package es.freetimelab.pluriwave
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.media.audiofx.Visualizer
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationManagerCompat
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
@@ -18,6 +20,7 @@ class MainActivity : AudioServiceActivity() {
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null
private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -40,10 +43,11 @@ class MainActivity : AudioServiceActivity() {
})
val alarmScheduler = AlarmScheduler(this)
MethodChannel(
alarmMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
alarmChannel
).setMethodCallHandler { call, result ->
)
alarmMethodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"scheduleAlarm" -> {
val id = call.argument<String>("id")
@@ -70,17 +74,45 @@ class MainActivity : AudioServiceActivity() {
result.success(
mapOf(
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
"notificationsEnabled" to true,
"notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
"manufacturer" to Build.MANUFACTURER,
"sdkInt" to Build.VERSION.SDK_INT
)
)
}
"getInitialAlarmIntent" -> {
result.success(alarmPayload(intent))
intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
}
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() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
checkSelfPermission(Manifest.permission.RECORD_AUDIO)
@@ -1,8 +1,14 @@
package es.freetimelab.pluriwave
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class PluriWaveAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@@ -20,21 +26,91 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
context.startActivity(launch)
}
ACTION_PRE_NOTICE -> {
// MVP: native delivery exists; Flutter will own skip-next UX.
// Next batch: notification channel + action button.
showPreNoticeNotification(context, alarmId, title)
}
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 {
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle"
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 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'modelos/alarma_musical.dart';
import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_alarma_sonando.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
@@ -13,6 +15,7 @@ import 'widgets/pluri_glass_surface.dart';
import 'widgets/pluri_icon.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key});
@@ -46,7 +49,9 @@ class _PaginaPrincipal extends StatefulWidget {
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
int _indice = 0;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false;
static const _paginas = [
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
void dispose() {
_errorSubscription?.cancel();
_alarmaSubscription?.cancel();
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) {
final estado = context.read<EstadoRadio>();
showModalBottomSheet(
+53
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../modelos/alarma_musical.dart';
@@ -21,12 +23,15 @@ class EstadoAlarmas extends ChangeNotifier {
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
List<ExcepcionAlarma> _excepciones = [];
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
bool _cargando = false;
String? _error;
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
bool get cargando => _cargando;
String? get error => _error;
@@ -47,6 +52,7 @@ class EstadoAlarmas extends ChangeNotifier {
_aplicar(config);
await _sincronizarTodas();
await cargarDiagnostico();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
} finally {
@@ -62,6 +68,13 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
Future<void> refrescarProgramacion() async {
final config = await servicio.recalcularTodas();
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> eliminarAlarma(String id) async {
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
@@ -96,6 +109,32 @@ class EstadoAlarmas extends ChangeNotifier {
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 {
try {
_diagnostico = await android.diagnostico();
@@ -114,5 +153,19 @@ class EstadoAlarmas extends ChangeNotifier {
void _aplicar(ConfiguracionAlarmas config) {
_alarmas = config.alarmas;
_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.tipoProgramacion,
required this.diasSemana,
this.fechaUnica,
this.emisora,
this.emisoraFallback,
this.activa = true,
@@ -31,6 +32,7 @@ class AlarmaMusical {
final int minuto;
final TipoProgramacionAlarma tipoProgramacion;
final List<int> diasSemana;
final DateTime? fechaUnica;
final Emisora? emisora;
final Emisora? emisoraFallback;
final bool sonarEnVacaciones;
@@ -49,6 +51,8 @@ class AlarmaMusical {
int? minuto,
TipoProgramacionAlarma? tipoProgramacion,
List<int>? diasSemana,
DateTime? fechaUnica,
bool limpiarFechaUnica = false,
Emisora? emisora,
Emisora? emisoraFallback,
bool? sonarEnVacaciones,
@@ -67,6 +71,7 @@ class AlarmaMusical {
minuto: minuto ?? this.minuto,
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
diasSemana: diasSemana ?? this.diasSemana,
fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica,
emisora: emisora ?? this.emisora,
emisoraFallback: emisoraFallback ?? this.emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
@@ -87,6 +92,7 @@ class AlarmaMusical {
'minuto': minuto,
'tipoProgramacion': tipoProgramacion.name,
'diasSemana': diasSemana,
'fechaUnica': fechaUnica?.toIso8601String(),
'emisora': emisora?.toMap(),
'emisoraFallback': emisoraFallback?.toMap(),
'sonarEnVacaciones': sonarEnVacaciones,
@@ -115,6 +121,7 @@ class AlarmaMusical {
.whereType<int>()
.where((d) => d >= DateTime.monday && d <= DateTime.sunday)
.toList(),
fechaUnica: _dateFromJson(json['fechaUnica']),
emisora: _emisoraFromJson(json['emisora']),
emisoraFallback: _emisoraFromJson(json['emisoraFallback']),
sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
@@ -166,13 +173,27 @@ class RangoVacaciones {
final DateTime fin;
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) {
final dia = DateTime(fecha.year, fecha.month, fecha.day);
final desde = DateTime(inicio.year, inicio.month, inicio.day);
final hasta = DateTime(fin.year, fin.month, fin.day);
final desde = inicioDia;
final hasta = finDia;
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() => {
'id': id,
'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,
) async {
final config = await cargar();
final normalizadas =
vacaciones
.map((v) => v.normalizado())
.toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
final alarmas =
config.alarmas
.map((a) => _recalcular(a, vacaciones, config.excepciones))
.map((a) => _recalcular(a, normalizadas, config.excepciones))
.toList();
final nuevo = ConfiguracionAlarmas(
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,
);
await _guardar(nuevo);
@@ -169,7 +205,13 @@ class ServicioAlarmas {
required int minuto,
required TipoProgramacionAlarma tipoProgramacion,
required List<int> diasSemana,
DateTime? fechaUnica,
Emisora? emisora,
Emisora? emisoraFallback,
bool sonarEnVacaciones = true,
int snoozeMinutos = 5,
double volumen = 0.85,
SonidoInternoAlarma sonidoInterno = SonidoInternoAlarma.amanecer,
}) {
final ahora = _reloj();
return AlarmaMusical(
@@ -179,7 +221,13 @@ class ServicioAlarmas {
minuto: minuto,
tipoProgramacion: tipoProgramacion,
diasSemana: diasSemana,
fechaUnica: fechaUnica,
emisora: emisora,
emisoraFallback: emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones,
snoozeMinutos: snoozeMinutos,
volumen: volumen,
sonidoInterno: sonidoInterno,
creadaEn: ahora,
actualizadaEn: ahora,
);
+54 -1
View File
@@ -1,7 +1,29 @@
import 'dart:async';
import 'package:flutter/services.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 {
const DiagnosticoAlarmasAndroid({
required this.puedeProgramarExactas,
@@ -28,9 +50,16 @@ class DiagnosticoAlarmasAndroid {
class ServicioAlarmasAndroid {
ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel;
}) : _channel = channel {
_instalarHandler(_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 {
final proxima = alarma.proximaEjecucion;
@@ -56,4 +85,28 @@ class ServicioAlarmasAndroid {
);
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;
final diaBase =
alarma.tipoProgramacion == TipoProgramacionAlarma.unica &&
alarma.fechaUnica != null
? alarma.fechaUnica!
: desde;
final inicio = DateTime(
desde.year,
desde.month,
desde.day,
diaBase.year,
diaBase.month,
diaBase.day,
alarma.hora,
alarma.minuto,
);
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) {
TipoProgramacionAlarma.unica =>
_esValida(alarma, primerCandidato, vacaciones, excepciones)
primerCandidato.isAfter(desde) &&
_esValida(alarma, primerCandidato, vacaciones, excepciones)
? primerCandidato
: null,
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
## Phase 1: domain and tests
- [ ] Add alarm domain models: alarm, vacation range, skip/exception, execution status.
- [ ] Add recurrence calculator with tests for one-shot, weekdays, vacations, skip-next, snooze.
- [ ] Add alarm persistence service with tests.
- [x] Add alarm domain models: alarm, vacation range, skip/exception, execution status.
- [x] Add recurrence calculator for one-shot dates, weekdays, vacations, skip-next, snooze.
- [x] Add alarm persistence service.
## Phase 2: Android scheduling bridge
- [ ] Add MethodChannel scheduler interface in Flutter.
- [ ] Add Kotlin scheduler using AlarmManager/setAlarmClock.
- [ ] Add BroadcastReceiver for alarm firing and pre-alarm actions.
- [ ] Add manifest permissions and receiver declarations.
- [ ] Add diagnostics method for exact alarm permission.
- [x] Add MethodChannel scheduler interface in Flutter.
- [x] Add Kotlin scheduler using AlarmManager/setAlarmClock.
- [x] Add BroadcastReceiver foundation for alarm firing and pre-alarm actions.
- [x] Add manifest permissions and receiver declarations.
- [x] Add diagnostics method for exact alarm permission.
## Phase 3: app state and UI
- [ ] Add `EstadoAlarmas` or integrate alarm slice without bloating `EstadoRadio`.
- [ ] Add alarms tab/entry point.
- [ ] Add alarm list, editor, vacation ranges UI, and diagnostics panel.
- [ ] Add ringing screen with stop/snooze 3/5/10.
- [x] Add `EstadoAlarmas` or integrate alarm slice without bloating `EstadoRadio`.
- [x] Add alarms tab/entry point.
- [x] Add alarm list, editor, automatic refresh, next execution/skip indication, and compact diagnostics access.
- [x] Add vacation ranges UI.
- [x] Add ringing screen with stop/snooze 3/5/10.
## Phase 4: audio fallback
- [ ] Add bundled internal alarm sounds under assets.
- [ ] Implement fallback sequence with timeouts.
- [ ] Add optional fallback station selection.
- [ ] Add volume/fade-in behavior.
- [x] Add premium generated alarm icon assets under assets.
- [x] Add bundled internal alarm sounds under assets.
- [x] Implement fallback sequence with timeouts.
- [x] Add optional fallback station selection.
- [x] Add volume behavior.
## Phase 5: verification
- [ ] Run `dart format`.
- [ ] Run `flutter analyze --no-fatal-infos`.
- [ ] Run targeted tests if local runner does not hang.
- [ ] Document Android limitations and permission flow.
- [x] Run `dart format` attempt; local formatter timed out in this environment.
- [x] Run `flutter analyze --no-fatal-infos`.
- [x] Run targeted static verification through analyzer; no build executed.
- [x] Document Android limitations and permission flow.
+2
View File
@@ -65,5 +65,7 @@ flutter:
assets:
- assets/images/
- assets/icons/
- assets/icons/alarmas/
- assets/audio/
- assets/mockups/
- assets/generated/