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.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"
|
||||
}
|
||||
}
|
||||
|
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: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(
|
||||
|
||||
@@ -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_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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||