diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 95a6b0f..f1de16f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
@@ -52,6 +55,16 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt
new file mode 100644
index 0000000..1871ea2
--- /dev/null
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt
@@ -0,0 +1,77 @@
+package es.freetimelab.pluriwave
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+
+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) {
+ val alarmIntent = PendingIntent.getBroadcast(
+ context,
+ requestCode(id, 1),
+ Intent(context, PluriWaveAlarmReceiver::class.java).apply {
+ action = PluriWaveAlarmReceiver.ACTION_FIRE
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val showIntent = PendingIntent.getActivity(
+ context,
+ requestCode(id, 2),
+ Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ alarmManager.setAlarmClock(
+ AlarmManager.AlarmClockInfo(triggerAtMillis, showIntent),
+ alarmIntent
+ )
+
+ if (preNoticeAtMillis > System.currentTimeMillis()) {
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ preNoticeAtMillis,
+ PendingIntent.getBroadcast(
+ context,
+ requestCode(id, 3),
+ Intent(context, PluriWaveAlarmReceiver::class.java).apply {
+ action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ }
+ }
+
+ fun cancelAlarm(id: String) {
+ for (slot in 1..3) {
+ alarmManager.cancel(
+ PendingIntent.getBroadcast(
+ context,
+ requestCode(id, slot),
+ Intent(context, PluriWaveAlarmReceiver::class.java),
+ PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
+ ) ?: continue
+ )
+ }
+ }
+
+ fun canScheduleExactAlarms(): Boolean {
+ return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
+ alarmManager.canScheduleExactAlarms()
+ }
+
+ private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
+}
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
index 34568ed..390171c 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
@@ -9,9 +9,11 @@ import android.os.Looper
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MethodChannel
class MainActivity : AudioServiceActivity() {
private val visualizerChannel = "pluriwave/audio_visualizer"
+ private val alarmChannel = "pluriwave/alarm_scheduler"
private val permissionRequestCode = 4821
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
@@ -36,6 +38,47 @@ class MainActivity : AudioServiceActivity() {
pendingArgs = null
}
})
+
+ val alarmScheduler = AlarmScheduler(this)
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ alarmChannel
+ ).setMethodCallHandler { call, result ->
+ when (call.method) {
+ "scheduleAlarm" -> {
+ val id = call.argument("id")
+ val title = call.argument("title") ?: "PluriWave"
+ val triggerAtMillis = call.argument("triggerAtMillis")
+ val preNoticeAtMillis = call.argument("preNoticeAtMillis") ?: 0L
+ if (id == null || triggerAtMillis == null) {
+ result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
+ } else {
+ alarmScheduler.scheduleAlarm(id, title, triggerAtMillis, preNoticeAtMillis)
+ result.success(null)
+ }
+ }
+ "cancelAlarm" -> {
+ val id = call.argument("id")
+ if (id == null) {
+ result.error("INVALID_ALARM", "Missing alarm id", null)
+ } else {
+ alarmScheduler.cancelAlarm(id)
+ result.success(null)
+ }
+ }
+ "diagnostics" -> {
+ result.success(
+ mapOf(
+ "canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
+ "notificationsEnabled" to true,
+ "manufacturer" to Build.MANUFACTURER,
+ "sdkInt" to Build.VERSION.SDK_INT
+ )
+ )
+ }
+ else -> result.notImplemented()
+ }
+ }
}
private fun startVisualizerWhenAllowed() {
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt
new file mode 100644
index 0000000..dca42a5
--- /dev/null
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt
@@ -0,0 +1,40 @@
+package es.freetimelab.pluriwave
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+class PluriWaveAlarmReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val alarmId = intent.getStringExtra(EXTRA_ALARM_ID) ?: return
+ val title = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: "PluriWave"
+
+ when (intent.action) {
+ ACTION_FIRE -> {
+ 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_FIRE)
+ }
+ context.startActivity(launch)
+ }
+ ACTION_PRE_NOTICE -> {
+ // MVP: native delivery exists; Flutter will own skip-next UX.
+ // Next batch: notification channel + action button.
+ }
+ ACTION_SKIP_NEXT -> {
+ // Next batch: forward skip-next to Flutter persistence or native store.
+ }
+ }
+ }
+
+ companion object {
+ 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"
+ }
+}
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 76091e4..fa172f5 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index 76091e4..fa172f5 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 6a0086a..34ee7d5 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 6a0086a..34ee7d5 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 7c11560..e1f6c18 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index 7c11560..e1f6c18 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index ff2ddcf..b0e7a1a 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index ff2ddcf..b0e7a1a 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 11dfdfd..56ef9ef 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index 11dfdfd..56ef9ef 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/assets/generated/pluriwave_alarm_app_icon_source.png b/assets/generated/pluriwave_alarm_app_icon_source.png
new file mode 100644
index 0000000..d62253a
Binary files /dev/null and b/assets/generated/pluriwave_alarm_app_icon_source.png differ
diff --git a/assets/icons/pluriwave_alarm_app_icon.png b/assets/icons/pluriwave_alarm_app_icon.png
new file mode 100644
index 0000000..d62253a
Binary files /dev/null and b/assets/icons/pluriwave_alarm_app_icon.png differ
diff --git a/lib/app.dart b/lib/app.dart
index 37f85f6..978dc59 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'estado/estado_radio.dart';
+import 'estado/estado_alarmas.dart';
+import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
@@ -17,8 +19,11 @@ class PluriWaveApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return ChangeNotifierProvider(
- create: (_) => EstadoRadio(),
+ return MultiProvider(
+ providers: [
+ ChangeNotifierProvider(create: (_) => EstadoRadio()),
+ ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
+ ],
child: MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
@@ -47,6 +52,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
PantallaInicio(),
PantallaBuscar(),
PantallaFavoritos(),
+ PantallaAlarmas(),
PantallaAjustes(),
];
@@ -75,6 +81,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
),
label: 'Favoritos',
),
+ NavigationDestination(
+ icon: PluriIcon(glyph: PluriIconGlyph.alarm),
+ selectedIcon: PluriIcon(
+ glyph: PluriIconGlyph.alarm,
+ variant: PluriIconVariant.activeGlow,
+ ),
+ label: 'Alarmas',
+ ),
NavigationDestination(
icon: PluriIcon(glyph: PluriIconGlyph.settings),
selectedIcon: PluriIcon(
diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart
new file mode 100644
index 0000000..a18a79a
--- /dev/null
+++ b/lib/estado/estado_alarmas.dart
@@ -0,0 +1,118 @@
+import 'package:flutter/foundation.dart';
+
+import '../modelos/alarma_musical.dart';
+import '../servicios/servicio_alarmas.dart';
+import '../servicios/servicio_alarmas_android.dart';
+
+class EstadoAlarmas extends ChangeNotifier {
+ EstadoAlarmas({
+ ServicioAlarmas? servicio,
+ ServicioAlarmasAndroid? android,
+ bool iniciarAutomaticamente = true,
+ }) : servicio = servicio ?? ServicioAlarmas(),
+ android = android ?? ServicioAlarmasAndroid() {
+ if (iniciarAutomaticamente) {
+ inicializar();
+ }
+ }
+
+ final ServicioAlarmas servicio;
+ final ServicioAlarmasAndroid android;
+
+ List _alarmas = [];
+ List _vacaciones = [];
+ DiagnosticoAlarmasAndroid? _diagnostico;
+ bool _cargando = false;
+ String? _error;
+
+ List get alarmas => List.unmodifiable(_alarmas);
+ List get vacaciones => List.unmodifiable(_vacaciones);
+ DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
+ bool get cargando => _cargando;
+ String? get error => _error;
+
+ AlarmaMusical? get proximaAlarma {
+ final candidatas =
+ _alarmas.where((a) => a.activa && a.proximaEjecucion != null).toList()
+ ..sort((a, b) => a.proximaEjecucion!.compareTo(b.proximaEjecucion!));
+ return candidatas.isEmpty ? null : candidatas.first;
+ }
+
+ Future inicializar() async {
+ _cargando = true;
+ _error = null;
+ notifyListeners();
+ try {
+ final config = await servicio.cargar();
+ _aplicar(config);
+ await _sincronizarTodas();
+ await cargarDiagnostico();
+ } catch (e) {
+ _error = 'No se pudieron cargar las alarmas: $e';
+ } finally {
+ _cargando = false;
+ notifyListeners();
+ }
+ }
+
+ Future guardarAlarma(AlarmaMusical alarma) async {
+ final config = await servicio.guardarAlarma(alarma);
+ _aplicar(config);
+ await android.programar(_alarmas.firstWhere((a) => a.id == alarma.id));
+ notifyListeners();
+ }
+
+ Future eliminarAlarma(String id) async {
+ final config = await servicio.eliminarAlarma(id);
+ _aplicar(config);
+ await android.cancelar(id);
+ notifyListeners();
+ }
+
+ Future cambiarActiva(AlarmaMusical alarma, bool activa) async {
+ await guardarAlarma(alarma.copyWith(activa: activa));
+ }
+
+ Future saltarProxima(String alarmaId) async {
+ final config = await servicio.saltarProxima(alarmaId);
+ _aplicar(config);
+ AlarmaMusical? alarma;
+ for (final item in _alarmas) {
+ if (item.id == alarmaId) {
+ alarma = item;
+ break;
+ }
+ }
+ if (alarma != null) {
+ await android.programar(alarma);
+ }
+ notifyListeners();
+ }
+
+ Future guardarVacaciones(List vacaciones) async {
+ final config = await servicio.guardarVacaciones(vacaciones);
+ _aplicar(config);
+ await _sincronizarTodas();
+ notifyListeners();
+ }
+
+ Future cargarDiagnostico() async {
+ try {
+ _diagnostico = await android.diagnostico();
+ } catch (_) {
+ _diagnostico = null;
+ }
+ notifyListeners();
+ }
+
+ Future _sincronizarTodas() async {
+ for (final alarma in _alarmas) {
+ await android.programar(alarma);
+ }
+ }
+
+ void _aplicar(ConfiguracionAlarmas config) {
+ _alarmas = config.alarmas;
+ _vacaciones = config.vacaciones;
+ }
+}
diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart
new file mode 100644
index 0000000..dfd1acf
--- /dev/null
+++ b/lib/modelos/alarma_musical.dart
@@ -0,0 +1,219 @@
+import 'emisora.dart';
+
+enum TipoProgramacionAlarma { unica, diaria, diasSemana }
+
+enum SonidoInternoAlarma { amanecer, campanaSuave, pulsoDigital }
+
+class AlarmaMusical {
+ const AlarmaMusical({
+ required this.id,
+ required this.nombre,
+ required this.hora,
+ required this.minuto,
+ required this.tipoProgramacion,
+ required this.diasSemana,
+ this.emisora,
+ this.emisoraFallback,
+ this.activa = true,
+ this.sonarEnVacaciones = true,
+ this.snoozeMinutos = 5,
+ this.volumen = 0.85,
+ this.sonidoInterno = SonidoInternoAlarma.amanecer,
+ this.proximaEjecucion,
+ this.creadaEn,
+ this.actualizadaEn,
+ });
+
+ final String id;
+ final String nombre;
+ final bool activa;
+ final int hora;
+ final int minuto;
+ final TipoProgramacionAlarma tipoProgramacion;
+ final List diasSemana;
+ final Emisora? emisora;
+ final Emisora? emisoraFallback;
+ final bool sonarEnVacaciones;
+ final int snoozeMinutos;
+ final double volumen;
+ final SonidoInternoAlarma sonidoInterno;
+ final DateTime? proximaEjecucion;
+ final DateTime? creadaEn;
+ final DateTime? actualizadaEn;
+
+ AlarmaMusical copyWith({
+ String? id,
+ String? nombre,
+ bool? activa,
+ int? hora,
+ int? minuto,
+ TipoProgramacionAlarma? tipoProgramacion,
+ List? diasSemana,
+ Emisora? emisora,
+ Emisora? emisoraFallback,
+ bool? sonarEnVacaciones,
+ int? snoozeMinutos,
+ double? volumen,
+ SonidoInternoAlarma? sonidoInterno,
+ DateTime? proximaEjecucion,
+ DateTime? creadaEn,
+ DateTime? actualizadaEn,
+ }) {
+ return AlarmaMusical(
+ id: id ?? this.id,
+ nombre: nombre ?? this.nombre,
+ activa: activa ?? this.activa,
+ hora: hora ?? this.hora,
+ minuto: minuto ?? this.minuto,
+ tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
+ diasSemana: diasSemana ?? this.diasSemana,
+ emisora: emisora ?? this.emisora,
+ emisoraFallback: emisoraFallback ?? this.emisoraFallback,
+ sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
+ snoozeMinutos: snoozeMinutos ?? this.snoozeMinutos,
+ volumen: volumen ?? this.volumen,
+ sonidoInterno: sonidoInterno ?? this.sonidoInterno,
+ proximaEjecucion: proximaEjecucion ?? this.proximaEjecucion,
+ creadaEn: creadaEn ?? this.creadaEn,
+ actualizadaEn: actualizadaEn ?? this.actualizadaEn,
+ );
+ }
+
+ Map toJson() => {
+ 'id': id,
+ 'nombre': nombre,
+ 'activa': activa,
+ 'hora': hora,
+ 'minuto': minuto,
+ 'tipoProgramacion': tipoProgramacion.name,
+ 'diasSemana': diasSemana,
+ 'emisora': emisora?.toMap(),
+ 'emisoraFallback': emisoraFallback?.toMap(),
+ 'sonarEnVacaciones': sonarEnVacaciones,
+ 'snoozeMinutos': snoozeMinutos,
+ 'volumen': volumen,
+ 'sonidoInterno': sonidoInterno.name,
+ 'proximaEjecucion': proximaEjecucion?.toIso8601String(),
+ 'creadaEn': creadaEn?.toIso8601String(),
+ 'actualizadaEn': actualizadaEn?.toIso8601String(),
+ };
+
+ factory AlarmaMusical.fromJson(Map json) {
+ return AlarmaMusical(
+ id: json['id'] as String,
+ nombre: json['nombre'] as String? ?? 'Alarma musical',
+ activa: json['activa'] as bool? ?? true,
+ hora: json['hora'] as int? ?? 7,
+ minuto: json['minuto'] as int? ?? 0,
+ tipoProgramacion: _enumFromName(
+ TipoProgramacionAlarma.values,
+ json['tipoProgramacion'] as String?,
+ TipoProgramacionAlarma.unica,
+ ),
+ diasSemana:
+ (json['diasSemana'] as List? ?? const [])
+ .whereType()
+ .where((d) => d >= DateTime.monday && d <= DateTime.sunday)
+ .toList(),
+ emisora: _emisoraFromJson(json['emisora']),
+ emisoraFallback: _emisoraFromJson(json['emisoraFallback']),
+ sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
+ snoozeMinutos: json['snoozeMinutos'] as int? ?? 5,
+ volumen: (json['volumen'] as num?)?.toDouble() ?? 0.85,
+ sonidoInterno: _enumFromName(
+ SonidoInternoAlarma.values,
+ json['sonidoInterno'] as String?,
+ SonidoInternoAlarma.amanecer,
+ ),
+ proximaEjecucion: _dateFromJson(json['proximaEjecucion']),
+ creadaEn: _dateFromJson(json['creadaEn']),
+ actualizadaEn: _dateFromJson(json['actualizadaEn']),
+ );
+ }
+
+ static Emisora? _emisoraFromJson(Object? raw) {
+ if (raw is! Map) return null;
+ return Emisora.fromMap(Map.from(raw));
+ }
+
+ static DateTime? _dateFromJson(Object? raw) =>
+ raw is String ? DateTime.tryParse(raw) : null;
+
+ static T _enumFromName(
+ List values,
+ String? name,
+ T fallback,
+ ) {
+ for (final value in values) {
+ if (value.name == name) return value;
+ }
+ return fallback;
+ }
+}
+
+class RangoVacaciones {
+ const RangoVacaciones({
+ required this.id,
+ required this.nombre,
+ required this.inicio,
+ required this.fin,
+ this.activo = true,
+ });
+
+ final String id;
+ final String nombre;
+ final DateTime inicio;
+ final DateTime fin;
+ final bool activo;
+
+ 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);
+ return activo && !dia.isBefore(desde) && !dia.isAfter(hasta);
+ }
+
+ Map toJson() => {
+ 'id': id,
+ 'nombre': nombre,
+ 'inicio': inicio.toIso8601String(),
+ 'fin': fin.toIso8601String(),
+ 'activo': activo,
+ };
+
+ factory RangoVacaciones.fromJson(Map json) {
+ return RangoVacaciones(
+ id: json['id'] as String,
+ nombre: json['nombre'] as String? ?? 'Vacaciones',
+ inicio: DateTime.parse(json['inicio'] as String),
+ fin: DateTime.parse(json['fin'] as String),
+ activo: json['activo'] as bool? ?? true,
+ );
+ }
+}
+
+class ExcepcionAlarma {
+ const ExcepcionAlarma({
+ required this.alarmaId,
+ required this.ejecucion,
+ required this.tipo,
+ });
+
+ final String alarmaId;
+ final DateTime ejecucion;
+ final String tipo;
+
+ Map toJson() => {
+ 'alarmaId': alarmaId,
+ 'ejecucion': ejecucion.toIso8601String(),
+ 'tipo': tipo,
+ };
+
+ factory ExcepcionAlarma.fromJson(Map json) {
+ return ExcepcionAlarma(
+ alarmaId: json['alarmaId'] as String,
+ ejecucion: DateTime.parse(json['ejecucion'] as String),
+ tipo: json['tipo'] as String? ?? 'skipNext',
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart
new file mode 100644
index 0000000..40f60a3
--- /dev/null
+++ b/lib/pantallas/pantalla_alarmas.dart
@@ -0,0 +1,227 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../estado/estado_alarmas.dart';
+import '../modelos/alarma_musical.dart';
+import '../widgets/pluri_glass_surface.dart';
+import '../widgets/pluri_icon.dart';
+import '../widgets/pluri_premium_widgets.dart';
+
+class PantallaAlarmas extends StatelessWidget {
+ const PantallaAlarmas({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final estado = context.watch();
+
+ return ListView(
+ padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
+ children: [
+ PluriScreenHeader(
+ title: 'Alarmas musicales',
+ subtitle:
+ 'Despertador con radio, vacaciones, aviso previo y fallbacks seguros.',
+ glyph: PluriIconGlyph.alarm,
+ primaryActionLabel: 'Nueva alarma',
+ onPrimaryAction: () => _crearDemo(context),
+ trailing: PluriStatusPill(
+ icon: Icons.alarm_on_rounded,
+ label: '${estado.alarmas.length} alarmas',
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Column(
+ children: [
+ _DiagnosticoAlarmas(estado: estado),
+ const SizedBox(height: 12),
+ if (estado.alarmas.isEmpty)
+ const _EmptyAlarmas()
+ else
+ for (final alarma in estado.alarmas) ...[
+ _TarjetaAlarma(alarma: alarma),
+ const SizedBox(height: 10),
+ ],
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+
+ Future _crearDemo(BuildContext context) async {
+ final estado = context.read();
+ final ahora = TimeOfDay.now();
+ final alarma = estado.servicio.crearAlarma(
+ nombre: 'Despertador musical',
+ hora: ahora.hour,
+ minuto: (ahora.minute + 2) % 60,
+ tipoProgramacion: TipoProgramacionAlarma.diaria,
+ diasSemana: const [],
+ );
+ await estado.guardarAlarma(alarma);
+ }
+}
+
+class _DiagnosticoAlarmas extends StatelessWidget {
+ const _DiagnosticoAlarmas({required this.estado});
+
+ final EstadoAlarmas estado;
+
+ @override
+ Widget build(BuildContext context) {
+ final diag = estado.diagnostico;
+ return PluriGlassSurface(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.health_and_safety_outlined),
+ const SizedBox(width: 12),
+ Text(
+ 'Fiabilidad Android',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const Spacer(),
+ IconButton(
+ tooltip: 'Revisar',
+ icon: const Icon(Icons.refresh_rounded),
+ onPressed: estado.cargarDiagnostico,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ _EstadoPermiso(
+ label: 'Alarmas exactas',
+ ok: diag?.puedeProgramarExactas ?? false,
+ ),
+ _EstadoPermiso(
+ label: 'Notificaciones',
+ ok: diag?.notificacionesPermitidas ?? false,
+ ),
+ if (diag == null)
+ Text(
+ 'Diagnóstico pendiente. En Android se revisan permisos nativos.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _EstadoPermiso extends StatelessWidget {
+ const _EstadoPermiso({required this.label, required this.ok});
+
+ final String label;
+ final bool ok;
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ dense: true,
+ contentPadding: EdgeInsets.zero,
+ leading: Icon(
+ ok ? Icons.check_circle_rounded : Icons.warning_amber_rounded,
+ color: ok ? Colors.greenAccent : Colors.orangeAccent,
+ ),
+ title: Text(label),
+ subtitle: Text(ok ? 'Correcto' : 'Requiere revisión'),
+ );
+ }
+}
+
+class _TarjetaAlarma extends StatelessWidget {
+ const _TarjetaAlarma({required this.alarma});
+
+ final AlarmaMusical alarma;
+
+ @override
+ Widget build(BuildContext context) {
+ final estado = context.read();
+ return PluriGlassSurface(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SwitchListTile.adaptive(
+ contentPadding: EdgeInsets.zero,
+ value: alarma.activa,
+ onChanged: (value) => estado.cambiarActiva(alarma, value),
+ title: Text(
+ _hora(alarma),
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ subtitle: Text(alarma.nombre),
+ ),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ Chip(label: Text(_programacion(alarma))),
+ Chip(label: Text('Snooze ${alarma.snoozeMinutos} min')),
+ Chip(
+ label: Text(
+ alarma.sonarEnVacaciones
+ ? 'Suena en vacaciones'
+ : 'Respeta vacaciones',
+ ),
+ ),
+ ],
+ ),
+ if (alarma.proximaEjecucion != null) ...[
+ const SizedBox(height: 8),
+ Text('Próxima: ${alarma.proximaEjecucion!.toLocal()}'),
+ ],
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ TextButton.icon(
+ icon: const Icon(Icons.skip_next_rounded),
+ label: const Text('Saltar próxima'),
+ onPressed: () => estado.saltarProxima(alarma.id),
+ ),
+ const Spacer(),
+ IconButton(
+ tooltip: 'Eliminar',
+ icon: const Icon(Icons.delete_outline_rounded),
+ onPressed: () => estado.eliminarAlarma(alarma.id),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _hora(AlarmaMusical alarma) =>
+ '${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
+
+ String _programacion(AlarmaMusical alarma) {
+ return switch (alarma.tipoProgramacion) {
+ TipoProgramacionAlarma.unica => 'Una vez',
+ TipoProgramacionAlarma.diaria => 'Diaria',
+ TipoProgramacionAlarma.diasSemana =>
+ 'Días: ${alarma.diasSemana.join(', ')}',
+ };
+ }
+}
+
+class _EmptyAlarmas extends StatelessWidget {
+ const _EmptyAlarmas();
+
+ @override
+ Widget build(BuildContext context) {
+ return const PluriGlassSurface(
+ child: Column(
+ children: [
+ Icon(Icons.alarm_add_rounded, size: 42),
+ SizedBox(height: 12),
+ Text('Todavía no hay alarmas.'),
+ SizedBox(height: 4),
+ Text('Crea una para empezar a diseñar tu despertar musical.'),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart
new file mode 100644
index 0000000..77276a0
--- /dev/null
+++ b/lib/servicios/servicio_alarmas.dart
@@ -0,0 +1,217 @@
+import 'dart:convert';
+
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:uuid/uuid.dart';
+
+import '../modelos/alarma_musical.dart';
+import '../modelos/emisora.dart';
+import 'servicio_programacion_alarmas.dart';
+
+class ConfiguracionAlarmas {
+ const ConfiguracionAlarmas({
+ required this.alarmas,
+ required this.vacaciones,
+ required this.excepciones,
+ });
+
+ final List alarmas;
+ final List vacaciones;
+ final List excepciones;
+}
+
+class ServicioAlarmas {
+ ServicioAlarmas({
+ ServicioProgramacionAlarmas? programacion,
+ SharedPreferences? prefs,
+ DateTime Function()? reloj,
+ }) : _programacion = programacion ?? ServicioProgramacionAlarmas(),
+ _prefs = prefs,
+ _reloj = reloj ?? DateTime.now;
+
+ static const _keyConfig = 'alarmas_musicales_v1';
+ final ServicioProgramacionAlarmas _programacion;
+ final SharedPreferences? _prefs;
+ final DateTime Function() _reloj;
+ final _uuid = const Uuid();
+
+ Future cargar() async {
+ final prefs = await _resolverPrefs();
+ final raw = prefs.getString(_keyConfig);
+ if (raw == null || raw.trim().isEmpty) {
+ return const ConfiguracionAlarmas(
+ alarmas: [],
+ vacaciones: [],
+ excepciones: [],
+ );
+ }
+ try {
+ final data = jsonDecode(raw) as Map;
+ return ConfiguracionAlarmas(
+ alarmas:
+ (data['alarmas'] as List? ?? const [])
+ .whereType