feat(alarm): add musical alarm foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m45s

This commit is contained in:
2026-05-21 23:46:52 +02:00
parent 8c2cba093c
commit fb808ebb60
30 changed files with 1437 additions and 43 deletions
+13
View File
@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
@@ -52,6 +55,16 @@
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveAlarmReceiver"
android:exported="false">
<intent-filter>
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/>
<action android:name="es.freetimelab.pluriwave.alarm.SKIP_NEXT"/>
</intent-filter>
</receiver>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -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
}
@@ -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<String>("id")
val title = call.argument<String>("title") ?: "PluriWave"
val triggerAtMillis = call.argument<Long>("triggerAtMillis")
val preNoticeAtMillis = call.argument<Long>("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<String>("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() {
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+16 -2
View File
@@ -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(
+118
View File
@@ -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<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
DiagnosticoAlarmasAndroid? _diagnostico;
bool _cargando = false;
String? _error;
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> 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<void> 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<void> guardarAlarma(AlarmaMusical alarma) async {
final config = await servicio.guardarAlarma(alarma);
_aplicar(config);
await android.programar(_alarmas.firstWhere((a) => a.id == alarma.id));
notifyListeners();
}
Future<void> eliminarAlarma(String id) async {
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
await android.cancelar(id);
notifyListeners();
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
Future<void> 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<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
final config = await servicio.guardarVacaciones(vacaciones);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> cargarDiagnostico() async {
try {
_diagnostico = await android.diagnostico();
} catch (_) {
_diagnostico = null;
}
notifyListeners();
}
Future<void> _sincronizarTodas() async {
for (final alarma in _alarmas) {
await android.programar(alarma);
}
}
void _aplicar(ConfiguracionAlarmas config) {
_alarmas = config.alarmas;
_vacaciones = config.vacaciones;
}
}
+219
View File
@@ -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<int> 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<int>? 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<String, dynamic> 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<String, dynamic> 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<int>()
.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<String, dynamic>.from(raw));
}
static DateTime? _dateFromJson(Object? raw) =>
raw is String ? DateTime.tryParse(raw) : null;
static T _enumFromName<T extends Enum>(
List<T> 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<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
'inicio': inicio.toIso8601String(),
'fin': fin.toIso8601String(),
'activo': activo,
};
factory RangoVacaciones.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'alarmaId': alarmaId,
'ejecucion': ejecucion.toIso8601String(),
'tipo': tipo,
};
factory ExcepcionAlarma.fromJson(Map<String, dynamic> json) {
return ExcepcionAlarma(
alarmaId: json['alarmaId'] as String,
ejecucion: DateTime.parse(json['ejecucion'] as String),
tipo: json['tipo'] as String? ?? 'skipNext',
);
}
}
+227
View File
@@ -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<EstadoAlarmas>();
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<void> _crearDemo(BuildContext context) async {
final estado = context.read<EstadoAlarmas>();
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<EstadoAlarmas>();
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.'),
],
),
);
}
}
+217
View File
@@ -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<AlarmaMusical> alarmas;
final List<RangoVacaciones> vacaciones;
final List<ExcepcionAlarma> 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<ConfiguracionAlarmas> 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<String, dynamic>;
return ConfiguracionAlarmas(
alarmas:
(data['alarmas'] as List? ?? const [])
.whereType<Map>()
.map(
(e) => AlarmaMusical.fromJson(Map<String, dynamic>.from(e)),
)
.toList(),
vacaciones:
(data['vacaciones'] as List? ?? const [])
.whereType<Map>()
.map(
(e) => RangoVacaciones.fromJson(Map<String, dynamic>.from(e)),
)
.toList(),
excepciones:
(data['excepciones'] as List? ?? const [])
.whereType<Map>()
.map(
(e) => ExcepcionAlarma.fromJson(Map<String, dynamic>.from(e)),
)
.toList(),
);
} catch (_) {
return const ConfiguracionAlarmas(
alarmas: [],
vacaciones: [],
excepciones: [],
);
}
}
Future<ConfiguracionAlarmas> guardarAlarma(
AlarmaMusical alarma, {
List<RangoVacaciones>? vacaciones,
List<ExcepcionAlarma>? excepciones,
}) async {
final config = await cargar();
final ahora = _reloj();
final alarmas = List<AlarmaMusical>.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<ConfiguracionAlarmas> 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<ConfiguracionAlarmas> guardarVacaciones(
List<RangoVacaciones> 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<ConfiguracionAlarmas> 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<int> 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<void> _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<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
return alarma.copyWith(
proximaEjecucion: _programacion.calcularProxima(
alarma: alarma,
desde: _reloj(),
vacaciones: vacaciones,
excepciones: excepciones,
),
);
}
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
}
@@ -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<Object?, Object?> 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<void> programar(AlarmaMusical alarma) async {
final proxima = alarma.proximaEjecucion;
if (proxima == null || !alarma.activa) {
await cancelar(alarma.id);
return;
}
await _channel.invokeMethod<void>('scheduleAlarm', {
'id': alarma.id,
'title': alarma.nombre,
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch,
});
}
Future<void> cancelar(String alarmaId) =>
_channel.invokeMethod<void>('cancelAlarm', {'id': alarmaId});
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'diagnostics',
);
return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
}
}
@@ -0,0 +1,104 @@
import '../modelos/alarma_musical.dart';
class ServicioProgramacionAlarmas {
DateTime? calcularProxima({
required AlarmaMusical alarma,
required DateTime desde,
List<RangoVacaciones> vacaciones = const [],
List<ExcepcionAlarma> 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<RangoVacaciones> vacaciones) =>
vacaciones.any((rango) => rango.contiene(fecha));
DateTime? _buscarDiaria(
AlarmaMusical alarma,
DateTime candidato,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> 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<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> 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<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> 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;
}
+49 -41
View File
@@ -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',
};
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1,6 @@
change: alarm-clock-module
status: planned
artifact_store: hybrid
created: 2026-05-21
updated: 2026-05-21
phase: tasks-ready
@@ -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.
@@ -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),
);
});
});
}