feat(alarm): add musical alarm foundation
@@ -5,6 +5,9 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<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_COARSE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
|
||||||
@@ -52,6 +55,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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 com.ryanheise.audioservice.AudioServiceActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : AudioServiceActivity() {
|
class MainActivity : AudioServiceActivity() {
|
||||||
private val visualizerChannel = "pluriwave/audio_visualizer"
|
private val visualizerChannel = "pluriwave/audio_visualizer"
|
||||||
|
private val alarmChannel = "pluriwave/alarm_scheduler"
|
||||||
private val permissionRequestCode = 4821
|
private val permissionRequestCode = 4821
|
||||||
private var visualizer: Visualizer? = null
|
private var visualizer: Visualizer? = null
|
||||||
private var pendingSink: EventChannel.EventSink? = null
|
private var pendingSink: EventChannel.EventSink? = null
|
||||||
@@ -36,6 +38,47 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
pendingArgs = null
|
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() {
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 656 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 656 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 452 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 452 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'estado/estado_radio.dart';
|
import 'estado/estado_radio.dart';
|
||||||
|
import 'estado/estado_alarmas.dart';
|
||||||
|
import 'pantallas/pantalla_alarmas.dart';
|
||||||
import 'pantallas/pantalla_inicio.dart';
|
import 'pantallas/pantalla_inicio.dart';
|
||||||
import 'pantallas/pantalla_buscar.dart';
|
import 'pantallas/pantalla_buscar.dart';
|
||||||
import 'pantallas/pantalla_favoritos.dart';
|
import 'pantallas/pantalla_favoritos.dart';
|
||||||
@@ -17,8 +19,11 @@ class PluriWaveApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ChangeNotifierProvider(
|
return MultiProvider(
|
||||||
create: (_) => EstadoRadio(),
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (_) => EstadoRadio()),
|
||||||
|
ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
|
||||||
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'PluriWave',
|
title: 'PluriWave',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
@@ -47,6 +52,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
|||||||
PantallaInicio(),
|
PantallaInicio(),
|
||||||
PantallaBuscar(),
|
PantallaBuscar(),
|
||||||
PantallaFavoritos(),
|
PantallaFavoritos(),
|
||||||
|
PantallaAlarmas(),
|
||||||
PantallaAjustes(),
|
PantallaAjustes(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,6 +81,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
|||||||
),
|
),
|
||||||
label: 'Favoritos',
|
label: 'Favoritos',
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: PluriIcon(glyph: PluriIconGlyph.alarm),
|
||||||
|
selectedIcon: PluriIcon(
|
||||||
|
glyph: PluriIconGlyph.alarm,
|
||||||
|
variant: PluriIconVariant.activeGlow,
|
||||||
|
),
|
||||||
|
label: 'Alarmas',
|
||||||
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: PluriIcon(glyph: PluriIconGlyph.settings),
|
icon: PluriIcon(glyph: PluriIconGlyph.settings),
|
||||||
selectedIcon: PluriIcon(
|
selectedIcon: PluriIcon(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../tema/pluriwave_tokens.dart';
|
import '../tema/pluriwave_tokens.dart';
|
||||||
import '../tema/pluriwave_theme.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 }
|
enum PluriIconVariant { outline, filled, activeGlow }
|
||||||
|
|
||||||
@@ -26,40 +26,40 @@ class PluriIcon extends StatelessWidget {
|
|||||||
final tokens = context.pluriTokens;
|
final tokens = context.pluriTokens;
|
||||||
final asset = _resolveAsset();
|
final asset = _resolveAsset();
|
||||||
final resolvedColor = _resolveColor(context, tokens);
|
final resolvedColor = _resolveColor(context, tokens);
|
||||||
final icon = asset == null
|
final icon =
|
||||||
? Icon(_resolveData(), size: size, color: resolvedColor)
|
asset == null
|
||||||
: Opacity(
|
? Icon(_resolveData(), size: size, color: resolvedColor)
|
||||||
opacity: variant == PluriIconVariant.outline ? 0.78 : 1,
|
: Opacity(
|
||||||
child: Image.asset(
|
opacity: variant == PluriIconVariant.outline ? 0.78 : 1,
|
||||||
asset,
|
child: Image.asset(
|
||||||
width: size,
|
asset,
|
||||||
height: size,
|
width: size,
|
||||||
fit: BoxFit.contain,
|
height: size,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
fit: BoxFit.contain,
|
||||||
_resolveData(),
|
errorBuilder:
|
||||||
size: size,
|
(_, __, ___) =>
|
||||||
color: resolvedColor,
|
Icon(_resolveData(), size: size, color: resolvedColor),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
final child =
|
||||||
final child = variant == PluriIconVariant.activeGlow
|
variant == PluriIconVariant.activeGlow
|
||||||
? Container(
|
? Container(
|
||||||
width: size + 14,
|
width: size + 14,
|
||||||
height: size + 14,
|
height: size + 14,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: tokens.glowColor,
|
color: tokens.glowColor,
|
||||||
blurRadius: 18,
|
blurRadius: 18,
|
||||||
spreadRadius: 1,
|
spreadRadius: 1,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: icon,
|
child: icon,
|
||||||
)
|
)
|
||||||
: icon;
|
: icon;
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: semanticLabel ?? _fallbackLabel(glyph),
|
label: semanticLabel ?? _fallbackLabel(glyph),
|
||||||
@@ -68,13 +68,12 @@ class PluriIcon extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
String? _resolveAsset() {
|
String? _resolveAsset() {
|
||||||
return switch (glyph) {
|
return switch (glyph) {
|
||||||
PluriIconGlyph.home => 'assets/icons/pluri_home.png',
|
PluriIconGlyph.home => 'assets/icons/pluri_home.png',
|
||||||
PluriIconGlyph.search => 'assets/icons/pluri_search.png',
|
PluriIconGlyph.search => 'assets/icons/pluri_search.png',
|
||||||
PluriIconGlyph.favorites => 'assets/icons/pluri_favorites.png',
|
PluriIconGlyph.favorites => 'assets/icons/pluri_favorites.png',
|
||||||
|
PluriIconGlyph.alarm => null,
|
||||||
PluriIconGlyph.player => 'assets/icons/pluri_player.png',
|
PluriIconGlyph.player => 'assets/icons/pluri_player.png',
|
||||||
PluriIconGlyph.settings => 'assets/icons/pluri_settings.png',
|
PluriIconGlyph.settings => 'assets/icons/pluri_settings.png',
|
||||||
};
|
};
|
||||||
@@ -82,7 +81,9 @@ class PluriIcon extends StatelessWidget {
|
|||||||
|
|
||||||
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
|
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
|
||||||
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
|
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);
|
return Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.78);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +91,19 @@ class PluriIcon extends StatelessWidget {
|
|||||||
return switch ((glyph, variant)) {
|
return switch ((glyph, variant)) {
|
||||||
(PluriIconGlyph.home, PluriIconVariant.outline) => Icons.home_outlined,
|
(PluriIconGlyph.home, PluriIconVariant.outline) => Icons.home_outlined,
|
||||||
(PluriIconGlyph.home, _) => Icons.home_rounded,
|
(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.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.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.player, _) => Icons.play_circle_rounded,
|
||||||
(PluriIconGlyph.settings, PluriIconVariant.outline) => Icons.settings_outlined,
|
(PluriIconGlyph.settings, PluriIconVariant.outline) =>
|
||||||
|
Icons.settings_outlined,
|
||||||
(PluriIconGlyph.settings, _) => Icons.settings_rounded,
|
(PluriIconGlyph.settings, _) => Icons.settings_rounded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,6 +113,7 @@ class PluriIcon extends StatelessWidget {
|
|||||||
PluriIconGlyph.home => 'Inicio',
|
PluriIconGlyph.home => 'Inicio',
|
||||||
PluriIconGlyph.search => 'Buscar',
|
PluriIconGlyph.search => 'Buscar',
|
||||||
PluriIconGlyph.favorites => 'Favoritos',
|
PluriIconGlyph.favorites => 'Favoritos',
|
||||||
|
PluriIconGlyph.alarm => 'Alarmas',
|
||||||
PluriIconGlyph.player => 'Reproductor',
|
PluriIconGlyph.player => 'Reproductor',
|
||||||
PluriIconGlyph.settings => 'Ajustes',
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||