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() + .map( + (e) => AlarmaMusical.fromJson(Map.from(e)), + ) + .toList(), + vacaciones: + (data['vacaciones'] as List? ?? const []) + .whereType() + .map( + (e) => RangoVacaciones.fromJson(Map.from(e)), + ) + .toList(), + excepciones: + (data['excepciones'] as List? ?? const []) + .whereType() + .map( + (e) => ExcepcionAlarma.fromJson(Map.from(e)), + ) + .toList(), + ); + } catch (_) { + return const ConfiguracionAlarmas( + alarmas: [], + vacaciones: [], + excepciones: [], + ); + } + } + + Future guardarAlarma( + AlarmaMusical alarma, { + List? vacaciones, + List? excepciones, + }) async { + final config = await cargar(); + final ahora = _reloj(); + final alarmas = List.from(config.alarmas); + final index = alarmas.indexWhere((a) => a.id == alarma.id); + final normalizada = _recalcular( + alarma.copyWith(creadaEn: alarma.creadaEn ?? ahora, actualizadaEn: ahora), + vacaciones ?? config.vacaciones, + excepciones ?? config.excepciones, + ); + + if (index >= 0) { + alarmas[index] = normalizada; + } else { + alarmas.add(normalizada); + } + final nuevo = ConfiguracionAlarmas( + alarmas: alarmas, + vacaciones: vacaciones ?? config.vacaciones, + excepciones: excepciones ?? config.excepciones, + ); + await _guardar(nuevo); + return nuevo; + } + + Future eliminarAlarma(String id) async { + final config = await cargar(); + final nuevo = ConfiguracionAlarmas( + alarmas: config.alarmas.where((a) => a.id != id).toList(), + vacaciones: config.vacaciones, + excepciones: config.excepciones.where((e) => e.alarmaId != id).toList(), + ); + await _guardar(nuevo); + return nuevo; + } + + Future guardarVacaciones( + List vacaciones, + ) async { + final config = await cargar(); + final alarmas = + config.alarmas + .map((a) => _recalcular(a, vacaciones, config.excepciones)) + .toList(); + final nuevo = ConfiguracionAlarmas( + alarmas: alarmas, + vacaciones: vacaciones, + excepciones: config.excepciones, + ); + await _guardar(nuevo); + return nuevo; + } + + Future saltarProxima(String alarmaId) async { + final config = await cargar(); + final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId); + final proxima = alarma.proximaEjecucion; + if (proxima == null) return config; + + final excepciones = [ + ...config.excepciones, + ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'), + ]; + final alarmas = + config.alarmas + .map( + (a) => + a.id == alarmaId + ? _recalcular(a, config.vacaciones, excepciones) + : a, + ) + .toList(); + final nuevo = ConfiguracionAlarmas( + alarmas: alarmas, + vacaciones: config.vacaciones, + excepciones: excepciones, + ); + await _guardar(nuevo); + return nuevo; + } + + AlarmaMusical crearAlarma({ + required String nombre, + required int hora, + required int minuto, + required TipoProgramacionAlarma tipoProgramacion, + required List diasSemana, + Emisora? emisora, + }) { + final ahora = _reloj(); + return AlarmaMusical( + id: _uuid.v4(), + nombre: nombre, + hora: hora, + minuto: minuto, + tipoProgramacion: tipoProgramacion, + diasSemana: diasSemana, + emisora: emisora, + creadaEn: ahora, + actualizadaEn: ahora, + ); + } + + Future _guardar(ConfiguracionAlarmas config) async { + final prefs = await _resolverPrefs(); + await prefs.setString( + _keyConfig, + jsonEncode({ + 'alarmas': config.alarmas.map((a) => a.toJson()).toList(), + 'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(), + 'excepciones': config.excepciones.map((e) => e.toJson()).toList(), + }), + ); + } + + AlarmaMusical _recalcular( + AlarmaMusical alarma, + List vacaciones, + List excepciones, + ) { + return alarma.copyWith( + proximaEjecucion: _programacion.calcularProxima( + alarma: alarma, + desde: _reloj(), + vacaciones: vacaciones, + excepciones: excepciones, + ), + ); + } + + Future _resolverPrefs() async => + _prefs ?? SharedPreferences.getInstance(); +} diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart new file mode 100644 index 0000000..e85d68e --- /dev/null +++ b/lib/servicios/servicio_alarmas_android.dart @@ -0,0 +1,59 @@ +import 'package:flutter/services.dart'; + +import '../modelos/alarma_musical.dart'; + +class DiagnosticoAlarmasAndroid { + const DiagnosticoAlarmasAndroid({ + required this.puedeProgramarExactas, + required this.notificacionesPermitidas, + required this.fabricante, + required this.versionSdk, + }); + + final bool puedeProgramarExactas; + final bool notificacionesPermitidas; + final String fabricante; + final int versionSdk; + + factory DiagnosticoAlarmasAndroid.fromMap(Map map) { + return DiagnosticoAlarmasAndroid( + puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true, + notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true, + fabricante: map['manufacturer'] as String? ?? 'Android', + versionSdk: map['sdkInt'] as int? ?? 0, + ); + } +} + +class ServicioAlarmasAndroid { + ServicioAlarmasAndroid({ + MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'), + }) : _channel = channel; + + final MethodChannel _channel; + + Future programar(AlarmaMusical alarma) async { + final proxima = alarma.proximaEjecucion; + if (proxima == null || !alarma.activa) { + await cancelar(alarma.id); + return; + } + await _channel.invokeMethod('scheduleAlarm', { + 'id': alarma.id, + 'title': alarma.nombre, + 'triggerAtMillis': proxima.millisecondsSinceEpoch, + 'preNoticeAtMillis': + proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch, + }); + } + + Future cancelar(String alarmaId) => + _channel.invokeMethod('cancelAlarm', {'id': alarmaId}); + + Future diagnostico() async { + final raw = await _channel.invokeMethod>( + 'diagnostics', + ); + return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {}); + } +} diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart new file mode 100644 index 0000000..54a7073 --- /dev/null +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -0,0 +1,104 @@ +import '../modelos/alarma_musical.dart'; + +class ServicioProgramacionAlarmas { + DateTime? calcularProxima({ + required AlarmaMusical alarma, + required DateTime desde, + List vacaciones = const [], + List excepciones = const [], + }) { + if (!alarma.activa) return null; + + final inicio = DateTime( + desde.year, + desde.month, + desde.day, + alarma.hora, + alarma.minuto, + ); + final primerCandidato = + inicio.isAfter(desde) ? inicio : inicio.add(const Duration(days: 1)); + + return switch (alarma.tipoProgramacion) { + TipoProgramacionAlarma.unica => + _esValida(alarma, primerCandidato, vacaciones, excepciones) + ? primerCandidato + : null, + TipoProgramacionAlarma.diaria => _buscarDiaria( + alarma, + primerCandidato, + vacaciones, + excepciones, + ), + TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana( + alarma, + primerCandidato, + vacaciones, + excepciones, + ), + }; + } + + DateTime calcularSnooze(DateTime desde, int minutos) { + final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5; + return desde.add(Duration(minutes: seguro)); + } + + bool estaEnVacaciones(DateTime fecha, List vacaciones) => + vacaciones.any((rango) => rango.contiene(fecha)); + + DateTime? _buscarDiaria( + AlarmaMusical alarma, + DateTime candidato, + List vacaciones, + List excepciones, + ) { + var actual = candidato; + for (var i = 0; i < 370; i++) { + if (_esValida(alarma, actual, vacaciones, excepciones)) return actual; + actual = actual.add(const Duration(days: 1)); + } + return null; + } + + DateTime? _buscarPorDiasSemana( + AlarmaMusical alarma, + DateTime candidato, + List vacaciones, + List excepciones, + ) { + if (alarma.diasSemana.isEmpty) return null; + var actual = candidato; + for (var i = 0; i < 370; i++) { + if (alarma.diasSemana.contains(actual.weekday) && + _esValida(alarma, actual, vacaciones, excepciones)) { + return actual; + } + actual = actual.add(const Duration(days: 1)); + } + return null; + } + + bool _esValida( + AlarmaMusical alarma, + DateTime candidato, + List vacaciones, + List excepciones, + ) { + if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) { + return false; + } + return !excepciones.any( + (excepcion) => + excepcion.alarmaId == alarma.id && + _mismaEjecucion(excepcion.ejecucion, candidato), + ); + } + + bool _mismaEjecucion(DateTime a, DateTime b) => + a.year == b.year && + a.month == b.month && + a.day == b.day && + a.hour == b.hour && + a.minute == b.minute; +} diff --git a/lib/widgets/pluri_icon.dart b/lib/widgets/pluri_icon.dart index 9220f94..cf8f559 100644 --- a/lib/widgets/pluri_icon.dart +++ b/lib/widgets/pluri_icon.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../tema/pluriwave_tokens.dart'; import '../tema/pluriwave_theme.dart'; -enum PluriIconGlyph { home, search, favorites, player, settings } +enum PluriIconGlyph { home, search, favorites, alarm, player, settings } enum PluriIconVariant { outline, filled, activeGlow } @@ -26,40 +26,40 @@ class PluriIcon extends StatelessWidget { final tokens = context.pluriTokens; final asset = _resolveAsset(); final resolvedColor = _resolveColor(context, tokens); - final icon = asset == null - ? Icon(_resolveData(), size: size, color: resolvedColor) - : Opacity( - opacity: variant == PluriIconVariant.outline ? 0.78 : 1, - child: Image.asset( - asset, - width: size, - height: size, - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => Icon( - _resolveData(), - size: size, - color: resolvedColor, + final icon = + asset == null + ? Icon(_resolveData(), size: size, color: resolvedColor) + : Opacity( + opacity: variant == PluriIconVariant.outline ? 0.78 : 1, + child: Image.asset( + asset, + width: size, + height: size, + fit: BoxFit.contain, + errorBuilder: + (_, __, ___) => + Icon(_resolveData(), size: size, color: resolvedColor), ), - ), - ); - final child = variant == PluriIconVariant.activeGlow - ? Container( - width: size + 14, - height: size + 14, - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: tokens.glowColor, - blurRadius: 18, - spreadRadius: 1, - ), - ], - ), - alignment: Alignment.center, - child: icon, - ) - : icon; + ); + final child = + variant == PluriIconVariant.activeGlow + ? Container( + width: size + 14, + height: size + 14, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: tokens.glowColor, + blurRadius: 18, + spreadRadius: 1, + ), + ], + ), + alignment: Alignment.center, + child: icon, + ) + : icon; return Semantics( label: semanticLabel ?? _fallbackLabel(glyph), @@ -68,13 +68,12 @@ class PluriIcon extends StatelessWidget { ); } - - String? _resolveAsset() { return switch (glyph) { PluriIconGlyph.home => 'assets/icons/pluri_home.png', PluriIconGlyph.search => 'assets/icons/pluri_search.png', PluriIconGlyph.favorites => 'assets/icons/pluri_favorites.png', + PluriIconGlyph.alarm => null, PluriIconGlyph.player => 'assets/icons/pluri_player.png', PluriIconGlyph.settings => 'assets/icons/pluri_settings.png', }; @@ -82,7 +81,9 @@ class PluriIcon extends StatelessWidget { Color _resolveColor(BuildContext context, PluriWaveTokens tokens) { if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta; - if (variant == PluriIconVariant.filled) return Theme.of(context).colorScheme.onSurface; + if (variant == PluriIconVariant.filled) { + return Theme.of(context).colorScheme.onSurface; + } return Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.78); } @@ -90,13 +91,19 @@ class PluriIcon extends StatelessWidget { return switch ((glyph, variant)) { (PluriIconGlyph.home, PluriIconVariant.outline) => Icons.home_outlined, (PluriIconGlyph.home, _) => Icons.home_rounded, - (PluriIconGlyph.search, PluriIconVariant.outline) => Icons.search_outlined, + (PluriIconGlyph.search, PluriIconVariant.outline) => + Icons.search_outlined, (PluriIconGlyph.search, _) => Icons.search_rounded, - (PluriIconGlyph.favorites, PluriIconVariant.outline) => Icons.favorite_border_rounded, + (PluriIconGlyph.favorites, PluriIconVariant.outline) => + Icons.favorite_border_rounded, (PluriIconGlyph.favorites, _) => Icons.favorite_rounded, - (PluriIconGlyph.player, PluriIconVariant.outline) => Icons.play_circle_outline_rounded, + (PluriIconGlyph.alarm, PluriIconVariant.outline) => Icons.alarm_outlined, + (PluriIconGlyph.alarm, _) => Icons.alarm_rounded, + (PluriIconGlyph.player, PluriIconVariant.outline) => + Icons.play_circle_outline_rounded, (PluriIconGlyph.player, _) => Icons.play_circle_rounded, - (PluriIconGlyph.settings, PluriIconVariant.outline) => Icons.settings_outlined, + (PluriIconGlyph.settings, PluriIconVariant.outline) => + Icons.settings_outlined, (PluriIconGlyph.settings, _) => Icons.settings_rounded, }; } @@ -106,6 +113,7 @@ class PluriIcon extends StatelessWidget { PluriIconGlyph.home => 'Inicio', PluriIconGlyph.search => 'Buscar', PluriIconGlyph.favorites => 'Favoritos', + PluriIconGlyph.alarm => 'Alarmas', PluriIconGlyph.player => 'Reproductor', PluriIconGlyph.settings => 'Ajustes', }; diff --git a/openspec/changes/alarm-clock-module/design.md b/openspec/changes/alarm-clock-module/design.md new file mode 100644 index 0000000..da2035d --- /dev/null +++ b/openspec/changes/alarm-clock-module/design.md @@ -0,0 +1,37 @@ +# Design: alarm-clock-module + +## Architecture +- Flutter owns alarm domain data, UX, recurrence calculation, and persistence. +- Android owns exact wakeup delivery through AlarmManager/setAlarmClock and notification actions. +- Communication uses a MethodChannel, tentatively `pluriwave/alarm_scheduler`. +- Existing `ServicioTimer` remains unchanged; a new `ServicioAlarmas` manages alarms. + +## Data model +- `AlarmaMusical`: id, name, enabled, hour, minute, scheduleType, weekdays, stationUuid/url snapshot, fallbackStation snapshot, bundledSoundId, volume, snoozeMinutes, soundOnVacation, nextOccurrenceAt. +- `RangoVacaciones`: id, name, startDate, endDate, enabled. +- `ExcepcionAlarma`: alarmId, occurrenceAt, type (`skipNext`, `snooze`, `vacation`). +- `EjecucionAlarma`: scheduledAt, firedAt, status, fallbackUsed, failureReason. + +## Persistence +Use JSON files or SharedPreferences for MVP to avoid risky DB migrations. If alarm history grows, migrate to sqflite later. + +## Android native components +- `PluriWaveAlarmReceiver`: receives exact alarm and pre-alarm actions. +- `PluriWaveAlarmScheduler`: schedules/cancels next alarm and pre-notification. +- `PluriWaveAlarmActivity` or full-screen notification target for the ringing UI. +- Notification channels: + - `alarm_pre_notice`: silent, low/default importance, no sound. + - `alarm_ringing`: high importance for active alarms. + +## Audio strategy +MVP: when the alarm fires, bring the Flutter app/alarm screen forward and use existing audio_service to play station/fallback. If Flutter/audio startup fails, Android should be able to play a bundled raw sound as last fallback. + +## Reliability diagnostics +Expose statuses for: +- exact alarm permission (`canScheduleExactAlarms`). +- notification permission. +- battery optimization warning. +- DND policy access for optional override. + +## Key decision +Use `setAlarmClock` for actual alarm occurrences because Android treats these as critical and visible user alarms. Use a separate exact/inexact notification alarm for the 30-minute silent pre-notice depending on permission and platform behavior. diff --git a/openspec/changes/alarm-clock-module/proposal.md b/openspec/changes/alarm-clock-module/proposal.md new file mode 100644 index 0000000..6eacbaf --- /dev/null +++ b/openspec/changes/alarm-clock-module/proposal.md @@ -0,0 +1,32 @@ +# Proposal: alarm-clock-module + +## Intent +Build an Android-first musical alarm system for PluriWave that can wake the user with radio/music while keeping reliable fallbacks and clear Android permission diagnostics. + +## Scope +- Add a new alarm domain separate from the existing sleep timer. +- Support one-shot and recurrent alarms by weekday. +- Support snooze options: 3, 5, and 10 minutes. +- Support a silent pre-alarm notification 30 minutes before the next occurrence, with an action to skip only that next execution. +- Support vacation ranges and per-alarm `soundOnVacation` behavior. Default: true. +- Support audio fallback chain: selected station, optional fallback station, bundled internal alarm sounds. +- Add Android native scheduling using AlarmManager/setAlarmClock via MethodChannel. +- Add Flutter UI for listing, editing, enabling/disabling, vacation ranges, and reliability diagnostics. + +## Out of Scope for MVP +- Cloud sync. +- iOS reliable alarm parity. +- Complex alarm-dismiss challenges. +- Multiple fallback station chains beyond one optional fallback station. +- Full background radio streaming implementation independent from existing audio_service if not needed for MVP. + +## Rollback Plan +- Alarm functionality is isolated behind new services/models/screens and Android receivers. +- Existing radio playback, timer, favorites, EQ, and recording flows should remain untouched except for navigation entry points. +- If native scheduling causes issues, remove Android manifest receiver/service entries and hide the alarms entry point. + +## Risks +- Android exact alarms require special permissions on Android 12+ and can be denied by default on Android 14+. +- OEM battery managers may still interfere; app must expose diagnostics and guidance. +- DND bypass requires Notification Policy Access and cannot be silently forced. +- Playing a radio stream at alarm time depends on network; bundled sounds must always be present. diff --git a/openspec/changes/alarm-clock-module/spec.md b/openspec/changes/alarm-clock-module/spec.md new file mode 100644 index 0000000..afe41fa --- /dev/null +++ b/openspec/changes/alarm-clock-module/spec.md @@ -0,0 +1,62 @@ +# Spec: alarm-clock-module + +## Requirement: Alarm scheduling +The app MUST support creating enabled/disabled alarms with a local time, one-shot or recurring schedule, and next occurrence calculation. + +### Scenario: one-shot alarm fires once +Given an enabled one-shot alarm for a future date/time +When its scheduled occurrence fires +Then the app MUST start the alarm flow +And the alarm MUST be disabled or marked completed after that occurrence unless snoozed. + +### Scenario: weekday recurring alarm +Given an enabled recurring alarm with selected weekdays +When the next matching weekday/time arrives +Then the app MUST start the alarm flow +And MUST schedule the following matching occurrence. + +## Requirement: Snooze +The app MUST offer snooze durations of 3, 5, and 10 minutes when an alarm is ringing. + +### Scenario: snooze selected +Given an alarm is ringing +When the user selects snooze 5 minutes +Then the app MUST stop current alarm playback +And MUST schedule a one-off snooze occurrence 5 minutes later. + +## Requirement: Pre-alarm notification +The app MUST schedule a silent notification 30 minutes before each next alarm occurrence when notification permission is available. + +### Scenario: skip next occurrence from notification +Given a pre-alarm notification is visible +When the user taps skip next occurrence +Then the app MUST record a skip for that alarm occurrence +And MUST not fire that specific occurrence +And MUST preserve future recurring occurrences. + +## Requirement: Vacation ranges +The app MUST support global vacation ranges with start/end dates and per-alarm `soundOnVacation` flag defaulting to true. + +### Scenario: alarm disabled for vacation date +Given today is inside an enabled vacation range +And an alarm has `soundOnVacation=false` +When calculating next occurrence +Then the app MUST skip occurrences inside that vacation range. + +## Requirement: Audio fallback +The app MUST never depend solely on an internet radio stream to ring. + +### Scenario: selected station fails +Given an alarm starts with a selected radio station +When the stream fails or does not become ready before timeout +Then the app MUST try a fallback station if configured +And otherwise MUST play a bundled internal alarm sound. + +## Requirement: Android reliability +The app MUST use native Android scheduling for alarm occurrences and expose permission/diagnostic status. + +### Scenario: exact alarm permission missing +Given Android denies exact alarm scheduling +When the user views alarm diagnostics +Then the app MUST show that exact alarm permission is missing +And MUST provide guidance to enable it. diff --git a/openspec/changes/alarm-clock-module/state.yaml b/openspec/changes/alarm-clock-module/state.yaml new file mode 100644 index 0000000..ce3d6ce --- /dev/null +++ b/openspec/changes/alarm-clock-module/state.yaml @@ -0,0 +1,6 @@ +change: alarm-clock-module +status: planned +artifact_store: hybrid +created: 2026-05-21 +updated: 2026-05-21 +phase: tasks-ready diff --git a/openspec/changes/alarm-clock-module/tasks.md b/openspec/changes/alarm-clock-module/tasks.md new file mode 100644 index 0000000..0cf1b7f --- /dev/null +++ b/openspec/changes/alarm-clock-module/tasks.md @@ -0,0 +1,31 @@ +# 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. + +## 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. + +## 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. + +## 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. + +## 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. diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart new file mode 100644 index 0000000..fbe8253 --- /dev/null +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_programacion_alarmas.dart'; + +void main() { + group('ServicioProgramacionAlarmas', () { + final servicio = ServicioProgramacionAlarmas(); + + test('calcula la próxima alarma diaria futura', () { + final alarma = AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 5, 21, 7), + ); + + expect(proxima, DateTime(2026, 5, 21, 7, 30)); + }); + + test('salta vacaciones si la alarma no debe sonar esos días', () { + final alarma = AlarmaMusical( + id: 'a2', + nombre: 'Laboral', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + sonarEnVacaciones: false, + ); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 8, 1, 7), + vacaciones: [ + RangoVacaciones( + id: 'v1', + nombre: 'Verano', + inicio: DateTime(2026, 8), + fin: DateTime(2026, 8, 3), + ), + ], + ); + + expect(proxima, DateTime(2026, 8, 4, 8)); + }); + + test('saltar próxima solo omite esa ejecución', () { + final alarma = AlarmaMusical( + id: 'a3', + nombre: 'Diaria', + hora: 9, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + final omitida = DateTime(2026, 5, 22, 9); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 5, 22, 8), + excepciones: [ + ExcepcionAlarma(alarmaId: 'a3', ejecucion: omitida, tipo: 'skipNext'), + ], + ); + + expect(proxima, DateTime(2026, 5, 23, 9)); + }); + + test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () { + expect( + servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 10), + DateTime(2026, 5, 21, 7, 10), + ); + expect( + servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 99), + DateTime(2026, 5, 21, 7, 5), + ); + }); + }); +}