feat(alarm): complete musical alarm flows
@@ -5,6 +5,7 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
||||||
class AlarmScheduler(private val context: Context) {
|
class AlarmScheduler(private val context: Context) {
|
||||||
private val alarmManager =
|
private val alarmManager =
|
||||||
@@ -28,6 +29,8 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
|
||||||
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
||||||
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
||||||
},
|
},
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
@@ -66,6 +69,9 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
) ?: continue
|
) ?: continue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
NotificationManagerCompat.from(context).cancel(
|
||||||
|
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canScheduleExactAlarms(): Boolean {
|
fun canScheduleExactAlarms(): Boolean {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package es.freetimelab.pluriwave
|
package es.freetimelab.pluriwave
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.audiofx.Visualizer
|
import android.media.audiofx.Visualizer
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
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
|
||||||
@@ -18,6 +20,7 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
private var visualizer: Visualizer? = null
|
private var visualizer: Visualizer? = null
|
||||||
private var pendingSink: EventChannel.EventSink? = null
|
private var pendingSink: EventChannel.EventSink? = null
|
||||||
private var pendingArgs: Map<*, *>? = null
|
private var pendingArgs: Map<*, *>? = null
|
||||||
|
private var alarmMethodChannel: MethodChannel? = null
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
@@ -40,10 +43,11 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
val alarmScheduler = AlarmScheduler(this)
|
val alarmScheduler = AlarmScheduler(this)
|
||||||
MethodChannel(
|
alarmMethodChannel = MethodChannel(
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
alarmChannel
|
alarmChannel
|
||||||
).setMethodCallHandler { call, result ->
|
)
|
||||||
|
alarmMethodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"scheduleAlarm" -> {
|
"scheduleAlarm" -> {
|
||||||
val id = call.argument<String>("id")
|
val id = call.argument<String>("id")
|
||||||
@@ -70,17 +74,45 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
result.success(
|
result.success(
|
||||||
mapOf(
|
mapOf(
|
||||||
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
|
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
|
||||||
"notificationsEnabled" to true,
|
"notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
|
||||||
"manufacturer" to Build.MANUFACTURER,
|
"manufacturer" to Build.MANUFACTURER,
|
||||||
"sdkInt" to Build.VERSION.SDK_INT
|
"sdkInt" to Build.VERSION.SDK_INT
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
"getInitialAlarmIntent" -> {
|
||||||
|
result.success(alarmPayload(intent))
|
||||||
|
intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
val payload = alarmPayload(intent)
|
||||||
|
if (payload.isNotEmpty()) {
|
||||||
|
alarmMethodChannel?.invokeMethod("alarmFired", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alarmPayload(intent: Intent?): Map<String, Any> {
|
||||||
|
if (intent == null) return emptyMap()
|
||||||
|
val action = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
|
||||||
|
?: return emptyMap()
|
||||||
|
val alarmId = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||||
|
?: return emptyMap()
|
||||||
|
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE)
|
||||||
|
?: "PluriWave"
|
||||||
|
return mapOf(
|
||||||
|
"alarmId" to alarmId,
|
||||||
|
"alarmTitle" to title,
|
||||||
|
"alarmAction" to action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun startVisualizerWhenAllowed() {
|
private fun startVisualizerWhenAllowed() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||||
checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package es.freetimelab.pluriwave
|
package es.freetimelab.pluriwave
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
||||||
class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
@@ -20,21 +26,91 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
|||||||
context.startActivity(launch)
|
context.startActivity(launch)
|
||||||
}
|
}
|
||||||
ACTION_PRE_NOTICE -> {
|
ACTION_PRE_NOTICE -> {
|
||||||
// MVP: native delivery exists; Flutter will own skip-next UX.
|
showPreNoticeNotification(context, alarmId, title)
|
||||||
// Next batch: notification channel + action button.
|
|
||||||
}
|
}
|
||||||
ACTION_SKIP_NEXT -> {
|
ACTION_SKIP_NEXT -> {
|
||||||
// Next batch: forward skip-next to Flutter persistence or native store.
|
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
||||||
|
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_SKIP_NEXT)
|
||||||
|
}
|
||||||
|
context.startActivity(launch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showPreNoticeNotification(context: Context, alarmId: String, title: String) {
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val openAppIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode(alarmId, 1),
|
||||||
|
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_PRE_NOTICE)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val skipNextIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
requestCode(alarmId, 2),
|
||||||
|
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
|
||||||
|
action = ACTION_SKIP_NEXT
|
||||||
|
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||||
|
putExtra(EXTRA_ALARM_TITLE, title)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText("Empieza en 30 minutos")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(openAppIntent)
|
||||||
|
.addAction(0, "Omitir siguiente", skipNextIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val existing = manager.getNotificationChannel(CHANNEL_ID)
|
||||||
|
if (existing != null) return
|
||||||
|
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Preavisos de alarmas",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Notificaciones silenciosas 30 minutos antes de la alarma"
|
||||||
|
setSound(null, null)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
|
||||||
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
|
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
|
||||||
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
|
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
|
||||||
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
|
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
|
||||||
const val EXTRA_ALARM_ID = "alarmId"
|
const val EXTRA_ALARM_ID = "alarmId"
|
||||||
const val EXTRA_ALARM_TITLE = "alarmTitle"
|
const val EXTRA_ALARM_TITLE = "alarmTitle"
|
||||||
const val EXTRA_ALARM_ACTION = "alarmAction"
|
const val EXTRA_ALARM_ACTION = "alarmAction"
|
||||||
|
|
||||||
|
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 405 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
# Alarmas Android en PluriWave
|
||||||
|
|
||||||
|
PluriWave programa las alarmas con `AlarmManager.setAlarmClock`, porque es el camino Android pensado para despertadores visibles y de alta fiabilidad. Flutter conserva la configuración, la UI, la emisora y los fallbacks; Android se encarga de despertar la app en el momento exacto.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. Flutter calcula la próxima ejecución según tipo, días, vacaciones y omisiones.
|
||||||
|
2. `ServicioAlarmasAndroid` envía la programación al `MethodChannel pluriwave/alarm_scheduler`.
|
||||||
|
3. `AlarmScheduler` registra:
|
||||||
|
- alarma principal con `setAlarmClock`;
|
||||||
|
- preaviso silencioso 30 minutos antes con `setExactAndAllowWhileIdle`.
|
||||||
|
4. `PluriWaveAlarmReceiver` abre la app cuando suena la alarma.
|
||||||
|
5. Flutter muestra `PantallaAlarmaSonando`, intenta reproducir la emisora y activa audio interno si la radio falla o tarda demasiado.
|
||||||
|
|
||||||
|
## Permisos
|
||||||
|
|
||||||
|
- `SCHEDULE_EXACT_ALARM`: necesario en Android 12+ para exactitud.
|
||||||
|
- `POST_NOTIFICATIONS`: necesario en Android 13+ para el preaviso silencioso.
|
||||||
|
- `WAKE_LOCK` y foreground media playback ya están declarados para la reproducción.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
Si la emisora no existe, falla o no empieza a reproducir en unos segundos, la pantalla usa sonidos internos incluidos en `assets/audio/`. Esto evita una alarma silenciosa por problemas de red o de radio.
|
||||||
|
|
||||||
|
## Vacaciones y omisiones
|
||||||
|
|
||||||
|
Las vacaciones se guardan en Flutter. Las alarmas configuradas para pausar en vacaciones saltan automáticamente esos rangos y muestran la próxima fecha válida. El preaviso permite omitir la siguiente ejecución abriendo la app y aplicando la misma lógica de omisión persistente.
|
||||||
@@ -3,7 +3,9 @@ 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 'estado/estado_alarmas.dart';
|
||||||
|
import 'modelos/alarma_musical.dart';
|
||||||
import 'pantallas/pantalla_alarmas.dart';
|
import 'pantallas/pantalla_alarmas.dart';
|
||||||
|
import 'pantallas/pantalla_alarma_sonando.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';
|
||||||
@@ -13,6 +15,7 @@ import 'widgets/pluri_glass_surface.dart';
|
|||||||
import 'widgets/pluri_icon.dart';
|
import 'widgets/pluri_icon.dart';
|
||||||
import 'widgets/pluri_wave_scaffold.dart';
|
import 'widgets/pluri_wave_scaffold.dart';
|
||||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||||
|
import 'servicios/servicio_alarmas_android.dart';
|
||||||
|
|
||||||
class PluriWaveApp extends StatelessWidget {
|
class PluriWaveApp extends StatelessWidget {
|
||||||
const PluriWaveApp({super.key});
|
const PluriWaveApp({super.key});
|
||||||
@@ -46,7 +49,9 @@ class _PaginaPrincipal extends StatefulWidget {
|
|||||||
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||||
int _indice = 0;
|
int _indice = 0;
|
||||||
StreamSubscription<String>? _errorSubscription;
|
StreamSubscription<String>? _errorSubscription;
|
||||||
|
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
|
||||||
EstadoRadio? _estadoSuscrito;
|
EstadoRadio? _estadoSuscrito;
|
||||||
|
bool _alarmaInicialProcesada = false;
|
||||||
|
|
||||||
static const _paginas = [
|
static const _paginas = [
|
||||||
PantallaInicio(),
|
PantallaInicio(),
|
||||||
@@ -118,11 +123,22 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final alarmas = context.read<EstadoAlarmas>();
|
||||||
|
_alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_abrirAlarmaSonando(evento);
|
||||||
|
});
|
||||||
|
if (!_alarmaInicialProcesada) {
|
||||||
|
_alarmaInicialProcesada = true;
|
||||||
|
unawaited(_procesarAlarmaInicial(alarmas));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_errorSubscription?.cancel();
|
_errorSubscription?.cancel();
|
||||||
|
_alarmaSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +181,47 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _procesarAlarmaInicial(EstadoAlarmas alarmas) async {
|
||||||
|
final evento = await alarmas.android.obtenerEventoInicial();
|
||||||
|
if (evento != null && mounted) {
|
||||||
|
await _abrirAlarmaSonando(evento);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
|
||||||
|
final estado = context.read<EstadoAlarmas>();
|
||||||
|
await estado.refrescarProgramacion();
|
||||||
|
AlarmaMusical? alarma;
|
||||||
|
for (final item in estado.alarmas) {
|
||||||
|
if (item.id == evento.alarmaId) {
|
||||||
|
alarma = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (alarma == null || !mounted) return;
|
||||||
|
if (evento.accion.endsWith('.SKIP_NEXT')) {
|
||||||
|
await estado.saltarProxima(alarma.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _indice = 3);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Omitida esta ejecución de ${alarma.nombre}.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evento.accion.endsWith('.PRE_NOTICE')) {
|
||||||
|
setState(() => _indice = 3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => PantallaAlarmaSonando(alarma: alarma!),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _mostrarTimerDialog(BuildContext context) {
|
void _mostrarTimerDialog(BuildContext context) {
|
||||||
final estado = context.read<EstadoRadio>();
|
final estado = context.read<EstadoRadio>();
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../modelos/alarma_musical.dart';
|
import '../modelos/alarma_musical.dart';
|
||||||
@@ -21,12 +23,15 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
|
|
||||||
List<AlarmaMusical> _alarmas = [];
|
List<AlarmaMusical> _alarmas = [];
|
||||||
List<RangoVacaciones> _vacaciones = [];
|
List<RangoVacaciones> _vacaciones = [];
|
||||||
|
List<ExcepcionAlarma> _excepciones = [];
|
||||||
DiagnosticoAlarmasAndroid? _diagnostico;
|
DiagnosticoAlarmasAndroid? _diagnostico;
|
||||||
|
Timer? _refresco;
|
||||||
bool _cargando = false;
|
bool _cargando = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
|
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
|
||||||
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
|
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
|
||||||
|
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
|
||||||
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
|
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
|
||||||
bool get cargando => _cargando;
|
bool get cargando => _cargando;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
@@ -47,6 +52,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
_aplicar(config);
|
_aplicar(config);
|
||||||
await _sincronizarTodas();
|
await _sincronizarTodas();
|
||||||
await cargarDiagnostico();
|
await cargarDiagnostico();
|
||||||
|
_activarRefresco();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'No se pudieron cargar las alarmas: $e';
|
_error = 'No se pudieron cargar las alarmas: $e';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,6 +68,13 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refrescarProgramacion() async {
|
||||||
|
final config = await servicio.recalcularTodas();
|
||||||
|
_aplicar(config);
|
||||||
|
await _sincronizarTodas();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> eliminarAlarma(String id) async {
|
Future<void> eliminarAlarma(String id) async {
|
||||||
final config = await servicio.eliminarAlarma(id);
|
final config = await servicio.eliminarAlarma(id);
|
||||||
_aplicar(config);
|
_aplicar(config);
|
||||||
@@ -96,6 +109,32 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
||||||
|
final proxima = DateTime.now().add(Duration(minutes: minutos));
|
||||||
|
await android.programar(alarma.copyWith(proximaEjecucion: proxima));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> finalizarEjecucion(String alarmaId) async {
|
||||||
|
await refrescarProgramacion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
|
||||||
|
final nuevos = [..._vacaciones, rango];
|
||||||
|
await guardarVacaciones(nuevos);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> eliminarRangoVacaciones(String id) async {
|
||||||
|
final nuevos = _vacaciones.where((v) => v.id != id).toList();
|
||||||
|
await guardarVacaciones(nuevos);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) {
|
||||||
|
final candidatas =
|
||||||
|
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
|
||||||
|
..sort((a, b) => b.ejecucion.compareTo(a.ejecucion));
|
||||||
|
return candidatas.isEmpty ? null : candidatas.first;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> cargarDiagnostico() async {
|
Future<void> cargarDiagnostico() async {
|
||||||
try {
|
try {
|
||||||
_diagnostico = await android.diagnostico();
|
_diagnostico = await android.diagnostico();
|
||||||
@@ -114,5 +153,19 @@ class EstadoAlarmas extends ChangeNotifier {
|
|||||||
void _aplicar(ConfiguracionAlarmas config) {
|
void _aplicar(ConfiguracionAlarmas config) {
|
||||||
_alarmas = config.alarmas;
|
_alarmas = config.alarmas;
|
||||||
_vacaciones = config.vacaciones;
|
_vacaciones = config.vacaciones;
|
||||||
|
_excepciones = config.excepciones;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _activarRefresco() {
|
||||||
|
_refresco?.cancel();
|
||||||
|
_refresco = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||||
|
refrescarProgramacion();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_refresco?.cancel();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class AlarmaMusical {
|
|||||||
required this.minuto,
|
required this.minuto,
|
||||||
required this.tipoProgramacion,
|
required this.tipoProgramacion,
|
||||||
required this.diasSemana,
|
required this.diasSemana,
|
||||||
|
this.fechaUnica,
|
||||||
this.emisora,
|
this.emisora,
|
||||||
this.emisoraFallback,
|
this.emisoraFallback,
|
||||||
this.activa = true,
|
this.activa = true,
|
||||||
@@ -31,6 +32,7 @@ class AlarmaMusical {
|
|||||||
final int minuto;
|
final int minuto;
|
||||||
final TipoProgramacionAlarma tipoProgramacion;
|
final TipoProgramacionAlarma tipoProgramacion;
|
||||||
final List<int> diasSemana;
|
final List<int> diasSemana;
|
||||||
|
final DateTime? fechaUnica;
|
||||||
final Emisora? emisora;
|
final Emisora? emisora;
|
||||||
final Emisora? emisoraFallback;
|
final Emisora? emisoraFallback;
|
||||||
final bool sonarEnVacaciones;
|
final bool sonarEnVacaciones;
|
||||||
@@ -49,6 +51,8 @@ class AlarmaMusical {
|
|||||||
int? minuto,
|
int? minuto,
|
||||||
TipoProgramacionAlarma? tipoProgramacion,
|
TipoProgramacionAlarma? tipoProgramacion,
|
||||||
List<int>? diasSemana,
|
List<int>? diasSemana,
|
||||||
|
DateTime? fechaUnica,
|
||||||
|
bool limpiarFechaUnica = false,
|
||||||
Emisora? emisora,
|
Emisora? emisora,
|
||||||
Emisora? emisoraFallback,
|
Emisora? emisoraFallback,
|
||||||
bool? sonarEnVacaciones,
|
bool? sonarEnVacaciones,
|
||||||
@@ -67,6 +71,7 @@ class AlarmaMusical {
|
|||||||
minuto: minuto ?? this.minuto,
|
minuto: minuto ?? this.minuto,
|
||||||
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
|
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
|
||||||
diasSemana: diasSemana ?? this.diasSemana,
|
diasSemana: diasSemana ?? this.diasSemana,
|
||||||
|
fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica,
|
||||||
emisora: emisora ?? this.emisora,
|
emisora: emisora ?? this.emisora,
|
||||||
emisoraFallback: emisoraFallback ?? this.emisoraFallback,
|
emisoraFallback: emisoraFallback ?? this.emisoraFallback,
|
||||||
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
|
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
|
||||||
@@ -87,6 +92,7 @@ class AlarmaMusical {
|
|||||||
'minuto': minuto,
|
'minuto': minuto,
|
||||||
'tipoProgramacion': tipoProgramacion.name,
|
'tipoProgramacion': tipoProgramacion.name,
|
||||||
'diasSemana': diasSemana,
|
'diasSemana': diasSemana,
|
||||||
|
'fechaUnica': fechaUnica?.toIso8601String(),
|
||||||
'emisora': emisora?.toMap(),
|
'emisora': emisora?.toMap(),
|
||||||
'emisoraFallback': emisoraFallback?.toMap(),
|
'emisoraFallback': emisoraFallback?.toMap(),
|
||||||
'sonarEnVacaciones': sonarEnVacaciones,
|
'sonarEnVacaciones': sonarEnVacaciones,
|
||||||
@@ -115,6 +121,7 @@ class AlarmaMusical {
|
|||||||
.whereType<int>()
|
.whereType<int>()
|
||||||
.where((d) => d >= DateTime.monday && d <= DateTime.sunday)
|
.where((d) => d >= DateTime.monday && d <= DateTime.sunday)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
fechaUnica: _dateFromJson(json['fechaUnica']),
|
||||||
emisora: _emisoraFromJson(json['emisora']),
|
emisora: _emisoraFromJson(json['emisora']),
|
||||||
emisoraFallback: _emisoraFromJson(json['emisoraFallback']),
|
emisoraFallback: _emisoraFromJson(json['emisoraFallback']),
|
||||||
sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
|
sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
|
||||||
@@ -166,13 +173,27 @@ class RangoVacaciones {
|
|||||||
final DateTime fin;
|
final DateTime fin;
|
||||||
final bool activo;
|
final bool activo;
|
||||||
|
|
||||||
|
DateTime get inicioDia => DateTime(inicio.year, inicio.month, inicio.day);
|
||||||
|
DateTime get finDia => DateTime(fin.year, fin.month, fin.day);
|
||||||
|
|
||||||
bool contiene(DateTime fecha) {
|
bool contiene(DateTime fecha) {
|
||||||
final dia = DateTime(fecha.year, fecha.month, fecha.day);
|
final dia = DateTime(fecha.year, fecha.month, fecha.day);
|
||||||
final desde = DateTime(inicio.year, inicio.month, inicio.day);
|
final desde = inicioDia;
|
||||||
final hasta = DateTime(fin.year, fin.month, fin.day);
|
final hasta = finDia;
|
||||||
return activo && !dia.isBefore(desde) && !dia.isAfter(hasta);
|
return activo && !dia.isBefore(desde) && !dia.isAfter(hasta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RangoVacaciones normalizado() {
|
||||||
|
if (!finDia.isBefore(inicioDia)) return this;
|
||||||
|
return RangoVacaciones(
|
||||||
|
id: id,
|
||||||
|
nombre: nombre,
|
||||||
|
inicio: finDia,
|
||||||
|
fin: inicioDia,
|
||||||
|
activo: activo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'nombre': nombre,
|
'nombre': nombre,
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../estado/estado_alarmas.dart';
|
||||||
|
import '../estado/estado_radio.dart';
|
||||||
|
import '../modelos/alarma_musical.dart';
|
||||||
|
import '../servicios/servicio_audio.dart';
|
||||||
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
|
|
||||||
|
class PantallaAlarmaSonando extends StatefulWidget {
|
||||||
|
const PantallaAlarmaSonando({super.key, required this.alarma});
|
||||||
|
|
||||||
|
final AlarmaMusical alarma;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PantallaAlarmaSonando> createState() => _PantallaAlarmaSonandoState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||||
|
final AudioPlayer _fallbackPlayer = AudioPlayer();
|
||||||
|
StreamSubscription<EstadoReproduccion>? _estadoSub;
|
||||||
|
Timer? _fallbackTimer;
|
||||||
|
bool _fallbackActivo = false;
|
||||||
|
bool _radioIntentada = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _iniciarAlarma() async {
|
||||||
|
final radio = context.read<EstadoRadio>();
|
||||||
|
await _fallbackPlayer.setVolume(widget.alarma.volumen.clamp(0.0, 1.0));
|
||||||
|
await _fallbackPlayer.setLoopMode(LoopMode.one);
|
||||||
|
|
||||||
|
final emisora = widget.alarma.emisora;
|
||||||
|
if (emisora == null) {
|
||||||
|
await _iniciarFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_radioIntentada = true;
|
||||||
|
await radio.audio.setVolumen(widget.alarma.volumen.clamp(0.0, 1.0));
|
||||||
|
unawaited(radio.reproducir(emisora));
|
||||||
|
|
||||||
|
_estadoSub = radio.estadoStream.listen((estado) {
|
||||||
|
if (estado == EstadoReproduccion.reproduciendo && mounted) {
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
}
|
||||||
|
if (estado == EstadoReproduccion.error && mounted) {
|
||||||
|
_iniciarFallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_fallbackTimer = Timer(const Duration(seconds: 12), () {
|
||||||
|
if (mounted) _iniciarFallback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _iniciarFallback() async {
|
||||||
|
if (_fallbackActivo) return;
|
||||||
|
_fallbackActivo = true;
|
||||||
|
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
|
||||||
|
await _fallbackPlayer.play();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _detener() async {
|
||||||
|
final radio = context.read<EstadoRadio>();
|
||||||
|
final alarmas = context.read<EstadoAlarmas>();
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
await _estadoSub?.cancel();
|
||||||
|
await _fallbackPlayer.stop();
|
||||||
|
await radio.audio.pausar();
|
||||||
|
await alarmas.finalizarEjecucion(widget.alarma.id);
|
||||||
|
if (mounted) navigator.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _posponer(int minutos) async {
|
||||||
|
final radio = context.read<EstadoRadio>();
|
||||||
|
final alarmas = context.read<EstadoAlarmas>();
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
await _estadoSub?.cancel();
|
||||||
|
await _fallbackPlayer.stop();
|
||||||
|
await radio.audio.pausar();
|
||||||
|
await alarmas.posponerAlarma(widget.alarma, minutos);
|
||||||
|
if (mounted) navigator.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
_estadoSub?.cancel();
|
||||||
|
_fallbackPlayer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final alarma = widget.alarma;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF061722),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Center(
|
||||||
|
child: PluriGlassSurface(
|
||||||
|
borderRadius: BorderRadius.circular(32),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.35),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/icons/alarmas/alarm_music.png',
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_hora(alarma),
|
||||||
|
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: -2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
alarma.nombre,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_fallbackActivo
|
||||||
|
? 'Sonando con audio seguro interno.'
|
||||||
|
: _radioIntentada
|
||||||
|
? 'Intentando reproducir tu emisora con máxima calidad disponible.'
|
||||||
|
: 'Preparando audio seguro interno.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _detener,
|
||||||
|
icon: const Icon(Icons.stop_rounded),
|
||||||
|
label: const Text('Detener alarma'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final min in const [3, 5, 10])
|
||||||
|
ActionChip(
|
||||||
|
avatar: const Icon(Icons.snooze_rounded, size: 18),
|
||||||
|
label: Text('Posponer $min min'),
|
||||||
|
onPressed: () => _posponer(min),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _assetFallback(SonidoInternoAlarma sonido) => switch (sonido) {
|
||||||
|
SonidoInternoAlarma.amanecer => 'assets/audio/alarm_amanecer.wav',
|
||||||
|
SonidoInternoAlarma.campanaSuave =>
|
||||||
|
'assets/audio/alarm_campana_suave.wav',
|
||||||
|
SonidoInternoAlarma.pulsoDigital => 'assets/audio/alarm_pulso_digital.wav',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _hora(AlarmaMusical alarma) =>
|
||||||
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../estado/estado_alarmas.dart';
|
import '../estado/estado_alarmas.dart';
|
||||||
|
import '../estado/estado_radio.dart';
|
||||||
import '../modelos/alarma_musical.dart';
|
import '../modelos/alarma_musical.dart';
|
||||||
|
import '../modelos/emisora.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
@@ -14,16 +16,18 @@ class PantallaAlarmas extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final estado = context.watch<EstadoAlarmas>();
|
final estado = context.watch<EstadoAlarmas>();
|
||||||
|
|
||||||
return ListView(
|
return RefreshIndicator(
|
||||||
|
onRefresh: estado.refrescarProgramacion,
|
||||||
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
|
padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
|
||||||
children: [
|
children: [
|
||||||
PluriScreenHeader(
|
PluriScreenHeader(
|
||||||
title: 'Alarmas musicales',
|
title: 'Despertar musical',
|
||||||
subtitle:
|
subtitle:
|
||||||
'Despertador con radio, vacaciones, aviso previo y fallbacks seguros.',
|
'Alarmas con radio, sonido seguro, vacaciones inteligentes y próxima ejecución siempre visible.',
|
||||||
glyph: PluriIconGlyph.alarm,
|
glyph: PluriIconGlyph.alarm,
|
||||||
primaryActionLabel: 'Nueva alarma',
|
primaryActionLabel: 'Crear alarma',
|
||||||
onPrimaryAction: () => _crearDemo(context),
|
onPrimaryAction: () => _abrirEditor(context),
|
||||||
trailing: PluriStatusPill(
|
trailing: PluriStatusPill(
|
||||||
icon: Icons.alarm_on_rounded,
|
icon: Icons.alarm_on_rounded,
|
||||||
label: '${estado.alarmas.length} alarmas',
|
label: '${estado.alarmas.length} alarmas',
|
||||||
@@ -33,77 +37,69 @@ class PantallaAlarmas extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_DiagnosticoAlarmas(estado: estado),
|
_PanelProximaAlarma(estado: estado),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (estado.alarmas.isEmpty)
|
if (estado.alarmas.isEmpty)
|
||||||
const _EmptyAlarmas()
|
const _EmptyAlarmas()
|
||||||
else
|
else
|
||||||
for (final alarma in estado.alarmas) ...[
|
for (final alarma in estado.alarmas) ...[
|
||||||
_TarjetaAlarma(alarma: alarma),
|
_TarjetaAlarma(alarma: alarma),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
|
_PanelVacaciones(estado: estado),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AccesoDiagnostico(estado: estado),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _crearDemo(BuildContext context) async {
|
Future<void> _abrirEditor(BuildContext context, {AlarmaMusical? alarma}) async {
|
||||||
final estado = context.read<EstadoAlarmas>();
|
await showModalBottomSheet<void>(
|
||||||
final ahora = TimeOfDay.now();
|
context: context,
|
||||||
final alarma = estado.servicio.crearAlarma(
|
isScrollControlled: true,
|
||||||
nombre: 'Despertador musical',
|
useSafeArea: true,
|
||||||
hora: ahora.hour,
|
backgroundColor: Colors.transparent,
|
||||||
minuto: (ahora.minute + 2) % 60,
|
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
||||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
|
||||||
diasSemana: const [],
|
|
||||||
);
|
);
|
||||||
await estado.guardarAlarma(alarma);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DiagnosticoAlarmas extends StatelessWidget {
|
class _PanelProximaAlarma extends StatelessWidget {
|
||||||
const _DiagnosticoAlarmas({required this.estado});
|
const _PanelProximaAlarma({required this.estado});
|
||||||
|
|
||||||
final EstadoAlarmas estado;
|
final EstadoAlarmas estado;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final diag = estado.diagnostico;
|
final proxima = estado.proximaAlarma;
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
|
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 72),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.health_and_safety_outlined),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
Text(
|
||||||
'Fiabilidad Android',
|
proxima == null ? 'Sin alarmas activas' : 'Próxima alarma',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
),
|
||||||
IconButton(
|
const SizedBox(height: 4),
|
||||||
tooltip: 'Revisar',
|
Text(
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
proxima == null
|
||||||
onPressed: estado.cargarDiagnostico,
|
? 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
|
||||||
|
: '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -111,27 +107,6 @@ class _DiagnosticoAlarmas extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
class _TarjetaAlarma extends StatelessWidget {
|
||||||
const _TarjetaAlarma({required this.alarma});
|
const _TarjetaAlarma({required this.alarma});
|
||||||
|
|
||||||
@@ -139,47 +114,119 @@ class _TarjetaAlarma extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final estado = context.read<EstadoAlarmas>();
|
final estado = context.watch<EstadoAlarmas>();
|
||||||
|
final excepcion = estado.ultimaExcepcionPara(alarma.id);
|
||||||
|
final mensajeVacaciones = _mensajeVacaciones(estado.vacaciones);
|
||||||
return PluriGlassSurface(
|
return PluriGlassSurface(
|
||||||
|
glowColor: const Color(0xFF22D3EE).withValues(alpha: 0.22),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile.adaptive(
|
Row(
|
||||||
contentPadding: EdgeInsets.zero,
|
children: [
|
||||||
|
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 64),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_hora(alarma),
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: -1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(alarma.nombre),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch.adaptive(
|
||||||
value: alarma.activa,
|
value: alarma.activa,
|
||||||
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
||||||
title: Text(
|
|
||||||
_hora(alarma),
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
),
|
||||||
subtitle: Text(alarma.nombre),
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Chip(label: Text(_programacion(alarma))),
|
_InfoChip(icon: Icons.repeat_rounded, label: _programacion(alarma)),
|
||||||
Chip(label: Text('Snooze ${alarma.snoozeMinutos} min')),
|
_InfoChip(icon: Icons.snooze_rounded, label: '${alarma.snoozeMinutos} min'),
|
||||||
Chip(
|
_InfoChip(
|
||||||
label: Text(
|
icon: Icons.beach_access_rounded,
|
||||||
alarma.sonarEnVacaciones
|
label: alarma.sonarEnVacaciones
|
||||||
? 'Suena en vacaciones'
|
? 'Suena en vacaciones'
|
||||||
: 'Respeta vacaciones',
|
: 'Pausa en vacaciones',
|
||||||
),
|
),
|
||||||
|
_InfoChip(
|
||||||
|
icon: Icons.volume_up_rounded,
|
||||||
|
label: '${(alarma.volumen * 100).round()}%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (alarma.proximaEjecucion != null) ...[
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 8),
|
if (alarma.proximaEjecucion != null)
|
||||||
Text('Próxima: ${alarma.proximaEjecucion!.toLocal()}'),
|
_NoticeLine(
|
||||||
|
icon: Icons.event_available_rounded,
|
||||||
|
text: 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const _NoticeLine(
|
||||||
|
icon: Icons.pause_circle_outline_rounded,
|
||||||
|
text: 'No tiene próxima ejecución activa.',
|
||||||
|
),
|
||||||
|
if (excepcion != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_NoticeLine(
|
||||||
|
icon: Icons.skip_next_rounded,
|
||||||
|
text: 'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 8),
|
if (mensajeVacaciones != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_NoticeLine(
|
||||||
|
icon: Icons.beach_access_rounded,
|
||||||
|
text: mensajeVacaciones,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.edit_rounded),
|
||||||
|
label: const Text('Editar'),
|
||||||
|
onPressed: () => _abrirEditor(context, alarma: alarma),
|
||||||
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.skip_next_rounded),
|
icon: const Icon(Icons.skip_next_rounded),
|
||||||
label: const Text('Saltar próxima'),
|
label: const Text('Omitir siguiente'),
|
||||||
onPressed: () => estado.saltarProxima(alarma.id),
|
onPressed: alarma.proximaEjecucion == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await estado.saltarProxima(alarma.id);
|
||||||
|
if (context.mounted) {
|
||||||
|
final alarmas =
|
||||||
|
context.read<EstadoAlarmas>().alarmas;
|
||||||
|
AlarmaMusical? actualizada;
|
||||||
|
for (final item in alarmas) {
|
||||||
|
if (item.id == alarma.id) {
|
||||||
|
actualizada = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
actualizada?.proximaEjecucion == null
|
||||||
|
? 'Alarma omitida. No queda próxima ejecución.'
|
||||||
|
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -194,16 +241,598 @@ class _TarjetaAlarma extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _hora(AlarmaMusical alarma) =>
|
String? _mensajeVacaciones(List<RangoVacaciones> vacaciones) {
|
||||||
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
if (alarma.sonarEnVacaciones) return null;
|
||||||
|
final ahora = DateTime.now();
|
||||||
|
RangoVacaciones? actual;
|
||||||
|
for (final rango in vacaciones) {
|
||||||
|
if (rango.contiene(ahora)) {
|
||||||
|
actual = rango;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actual != null) {
|
||||||
|
if (alarma.proximaEjecucion == null) {
|
||||||
|
return 'Está pausada por vacaciones (${actual.nombre}) y sin próxima ejecución.';
|
||||||
|
}
|
||||||
|
return 'Está pausada por vacaciones (${actual.nombre}) y vuelve el ${_fechaHora(alarma.proximaEjecucion!)}.';
|
||||||
|
}
|
||||||
|
if (alarma.proximaEjecucion != null) {
|
||||||
|
return 'Con vacaciones activas, volverá a sonar el ${_fechaHora(alarma.proximaEjecucion!)}.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
String _programacion(AlarmaMusical alarma) {
|
void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) {
|
||||||
return switch (alarma.tipoProgramacion) {
|
showModalBottomSheet<void>(
|
||||||
TipoProgramacionAlarma.unica => 'Una vez',
|
context: context,
|
||||||
TipoProgramacionAlarma.diaria => 'Diaria',
|
isScrollControlled: true,
|
||||||
TipoProgramacionAlarma.diasSemana =>
|
useSafeArea: true,
|
||||||
'Días: ${alarma.diasSemana.join(', ')}',
|
backgroundColor: Colors.transparent,
|
||||||
};
|
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorAlarmaSheet extends StatefulWidget {
|
||||||
|
const _EditorAlarmaSheet({this.alarma});
|
||||||
|
|
||||||
|
final AlarmaMusical? alarma;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EditorAlarmaSheet> createState() => _EditorAlarmaSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||||
|
late final TextEditingController _nombreController;
|
||||||
|
late TimeOfDay _hora;
|
||||||
|
late DateTime _fecha;
|
||||||
|
late TipoProgramacionAlarma _tipo;
|
||||||
|
late Set<int> _diasSemana;
|
||||||
|
late int _snooze;
|
||||||
|
late double _volumen;
|
||||||
|
late bool _sonarEnVacaciones;
|
||||||
|
late SonidoInternoAlarma _sonidoInterno;
|
||||||
|
Emisora? _emisora;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final alarma = widget.alarma;
|
||||||
|
final ahora = DateTime.now().add(const Duration(minutes: 5));
|
||||||
|
_nombreController = TextEditingController(
|
||||||
|
text: alarma?.nombre ?? 'Despertador musical',
|
||||||
|
);
|
||||||
|
_hora = TimeOfDay(hour: alarma?.hora ?? ahora.hour, minute: alarma?.minuto ?? ahora.minute);
|
||||||
|
_fecha = alarma?.fechaUnica ?? ahora;
|
||||||
|
_tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica;
|
||||||
|
_diasSemana = {...alarma?.diasSemana ?? const <int>[]};
|
||||||
|
_snooze = alarma?.snoozeMinutos ?? 5;
|
||||||
|
_volumen = alarma?.volumen ?? 0.85;
|
||||||
|
_sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true;
|
||||||
|
_sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer;
|
||||||
|
_emisora = alarma?.emisora;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nombreController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final radio = context.watch<EstadoRadio>();
|
||||||
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
|
||||||
|
child: PluriGlassSurface(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 58),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.alarma == null ? 'Nueva alarma' : 'Editar alarma',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
TextField(
|
||||||
|
controller: _nombreController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Nombre'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _PickerButton(
|
||||||
|
icon: Icons.schedule_rounded,
|
||||||
|
label: 'Hora',
|
||||||
|
value: _hora.format(context),
|
||||||
|
onTap: _elegirHora,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _PickerButton(
|
||||||
|
icon: Icons.event_rounded,
|
||||||
|
label: 'Fecha',
|
||||||
|
value: _fechaCorta(_fecha),
|
||||||
|
onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SegmentedButton<TipoProgramacionAlarma>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: TipoProgramacionAlarma.unica, label: Text('Una vez')),
|
||||||
|
ButtonSegment(value: TipoProgramacionAlarma.diaria, label: Text('Diaria')),
|
||||||
|
ButtonSegment(value: TipoProgramacionAlarma.diasSemana, label: Text('Días')),
|
||||||
|
],
|
||||||
|
selected: {_tipo},
|
||||||
|
onSelectionChanged: (value) => setState(() => _tipo = value.first),
|
||||||
|
),
|
||||||
|
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(_diaCorto(i)),
|
||||||
|
selected: _diasSemana.contains(i),
|
||||||
|
onSelected: (selected) => setState(() {
|
||||||
|
selected ? _diasSemana.add(i) : _diasSemana.remove(i);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SectionLabel(icon: 'assets/icons/alarmas/snooze_wave.png', text: 'Postponer'),
|
||||||
|
SegmentedButton<int>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: 3, label: Text('3 min')),
|
||||||
|
ButtonSegment(value: 5, label: Text('5 min')),
|
||||||
|
ButtonSegment(value: 10, label: Text('10 min')),
|
||||||
|
],
|
||||||
|
selected: {_snooze},
|
||||||
|
onSelectionChanged: (value) => setState(() => _snooze = value.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SectionLabel(icon: 'assets/icons/alarmas/fallback_sound.png', text: 'Sonido y volumen'),
|
||||||
|
Slider(
|
||||||
|
value: _volumen,
|
||||||
|
min: 0.25,
|
||||||
|
max: 1,
|
||||||
|
divisions: 15,
|
||||||
|
label: '${(_volumen * 100).round()}%',
|
||||||
|
onChanged: (value) => setState(() => _volumen = value),
|
||||||
|
),
|
||||||
|
DropdownButtonFormField<SonidoInternoAlarma>(
|
||||||
|
initialValue: _sonidoInterno,
|
||||||
|
decoration: const InputDecoration(labelText: 'Sonido seguro interno'),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: SonidoInternoAlarma.amanecer, child: Text('Amanecer cálido')),
|
||||||
|
DropdownMenuItem(value: SonidoInternoAlarma.campanaSuave, child: Text('Campana suave')),
|
||||||
|
DropdownMenuItem(value: SonidoInternoAlarma.pulsoDigital, child: Text('Pulso digital')),
|
||||||
|
],
|
||||||
|
onChanged: (value) => setState(() => _sonidoInterno = value ?? _sonidoInterno),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.radio_rounded),
|
||||||
|
title: Text(_emisora?.nombre ?? 'Sin emisora principal'),
|
||||||
|
subtitle: Text(
|
||||||
|
radio.emisoraActual == null
|
||||||
|
? 'Se usará el sonido interno si la radio falla.'
|
||||||
|
: 'Podés usar la emisora que está seleccionada ahora.',
|
||||||
|
),
|
||||||
|
trailing: FilledButton.tonal(
|
||||||
|
onPressed: radio.emisoraActual == null
|
||||||
|
? null
|
||||||
|
: () => setState(() => _emisora = radio.emisoraActual),
|
||||||
|
child: const Text('Usar actual'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: _sonarEnVacaciones,
|
||||||
|
onChanged: (value) => setState(() => _sonarEnVacaciones = value),
|
||||||
|
secondary: const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 42),
|
||||||
|
title: const Text('Sonar durante vacaciones'),
|
||||||
|
subtitle: const Text('Si lo apagás, la próxima ejecución saltará al primer día válido.'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _guardar,
|
||||||
|
icon: const Icon(Icons.check_rounded),
|
||||||
|
label: const Text('Guardar alarma'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _elegirHora() async {
|
||||||
|
final nueva = await showTimePicker(context: context, initialTime: _hora);
|
||||||
|
if (nueva != null) setState(() => _hora = nueva);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _elegirFecha() async {
|
||||||
|
final ahora = DateTime.now();
|
||||||
|
final nueva = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _fecha.isBefore(ahora) ? ahora : _fecha,
|
||||||
|
firstDate: DateTime(ahora.year, ahora.month, ahora.day),
|
||||||
|
lastDate: ahora.add(const Duration(days: 730)),
|
||||||
|
);
|
||||||
|
if (nueva != null) setState(() => _fecha = nueva);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _guardar() async {
|
||||||
|
if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Elegí al menos un día de la semana.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final estado = context.read<EstadoAlarmas>();
|
||||||
|
final existente = widget.alarma;
|
||||||
|
final alarma = (existente ??
|
||||||
|
estado.servicio.crearAlarma(
|
||||||
|
nombre: _nombreController.text.trim(),
|
||||||
|
hora: _hora.hour,
|
||||||
|
minuto: _hora.minute,
|
||||||
|
tipoProgramacion: _tipo,
|
||||||
|
diasSemana: _diasSemana.toList()..sort(),
|
||||||
|
))
|
||||||
|
.copyWith(
|
||||||
|
nombre: _nombreController.text.trim().isEmpty
|
||||||
|
? 'Despertador musical'
|
||||||
|
: _nombreController.text.trim(),
|
||||||
|
hora: _hora.hour,
|
||||||
|
minuto: _hora.minute,
|
||||||
|
tipoProgramacion: _tipo,
|
||||||
|
diasSemana: _tipo == TipoProgramacionAlarma.diasSemana
|
||||||
|
? (_diasSemana.toList()..sort())
|
||||||
|
: const [],
|
||||||
|
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
|
||||||
|
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
|
||||||
|
emisora: _emisora,
|
||||||
|
sonarEnVacaciones: _sonarEnVacaciones,
|
||||||
|
snoozeMinutos: _snooze,
|
||||||
|
volumen: _volumen,
|
||||||
|
sonidoInterno: _sonidoInterno,
|
||||||
|
activa: true,
|
||||||
|
);
|
||||||
|
await estado.guardarAlarma(alarma);
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccesoDiagnostico extends StatelessWidget {
|
||||||
|
const _AccesoDiagnostico({required this.estado});
|
||||||
|
|
||||||
|
final EstadoAlarmas estado;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final diag = estado.diagnostico;
|
||||||
|
return TextButton.icon(
|
||||||
|
icon: const _AssetIcon('assets/icons/alarmas/android_reliability.png', size: 28),
|
||||||
|
label: Text(
|
||||||
|
diag == null
|
||||||
|
? 'Revisar fiabilidad Android'
|
||||||
|
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} · notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'}',
|
||||||
|
),
|
||||||
|
onPressed: estado.cargarDiagnostico,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PanelVacaciones extends StatelessWidget {
|
||||||
|
const _PanelVacaciones({required this.estado});
|
||||||
|
|
||||||
|
final EstadoAlarmas estado;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final vacaciones = [...estado.vacaciones]
|
||||||
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
||||||
|
return PluriGlassSurface(
|
||||||
|
glowColor: const Color(0xFF60A5FA).withValues(alpha: 0.22),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 48),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Rangos de vacaciones',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _abrirAlta(context),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: const Text('Agregar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Si una alarma tiene "Pausa en vacaciones", se salta automáticamente estos rangos.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (vacaciones.isEmpty)
|
||||||
|
const Text('Sin rangos cargados.')
|
||||||
|
else
|
||||||
|
for (final rango in vacaciones)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.event_busy_rounded),
|
||||||
|
title: Text(rango.nombre),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_fechaCorta(rango.inicioDia)} → ${_fechaCorta(rango.finDia)}',
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: 'Eliminar rango',
|
||||||
|
onPressed: () => estado.eliminarRangoVacaciones(rango.id),
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _abrirAlta(BuildContext context) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => const _EditorVacacionesSheet(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorVacacionesSheet extends StatefulWidget {
|
||||||
|
const _EditorVacacionesSheet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||||
|
late final TextEditingController _nombreController;
|
||||||
|
late DateTime _inicio;
|
||||||
|
late DateTime _fin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final hoy = DateTime.now();
|
||||||
|
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
||||||
|
_fin = _inicio.add(const Duration(days: 2));
|
||||||
|
_nombreController = TextEditingController(text: 'Vacaciones');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nombreController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
|
||||||
|
child: PluriGlassSurface(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Nuevo rango de vacaciones',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _nombreController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Nombre'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _PickerButton(
|
||||||
|
icon: Icons.play_arrow_rounded,
|
||||||
|
label: 'Inicio',
|
||||||
|
value: _fechaCorta(_inicio),
|
||||||
|
onTap: () => _elegirFecha(esInicio: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _PickerButton(
|
||||||
|
icon: Icons.stop_rounded,
|
||||||
|
label: 'Fin',
|
||||||
|
value: _fechaCorta(_fin),
|
||||||
|
onTap: () => _elegirFecha(esInicio: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _guardar,
|
||||||
|
icon: const Icon(Icons.check_rounded),
|
||||||
|
label: const Text('Guardar rango'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _elegirFecha({required bool esInicio}) async {
|
||||||
|
final actual = esInicio ? _inicio : _fin;
|
||||||
|
final hoy = DateTime.now();
|
||||||
|
final seleccion = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: actual,
|
||||||
|
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
|
||||||
|
lastDate: hoy.add(const Duration(days: 1460)),
|
||||||
|
);
|
||||||
|
if (seleccion == null) return;
|
||||||
|
setState(() {
|
||||||
|
if (esInicio) {
|
||||||
|
_inicio = seleccion;
|
||||||
|
if (_fin.isBefore(_inicio)) _fin = _inicio;
|
||||||
|
} else {
|
||||||
|
_fin = seleccion;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _guardar() async {
|
||||||
|
final estado = context.read<EstadoAlarmas>();
|
||||||
|
final rango = estado.servicio.crearRangoVacaciones(
|
||||||
|
inicio: _inicio,
|
||||||
|
fin: _fin,
|
||||||
|
nombre: _nombreController.text.trim(),
|
||||||
|
);
|
||||||
|
await estado.crearRangoVacaciones(rango);
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetIcon extends StatelessWidget {
|
||||||
|
const _AssetIcon(this.asset, {this.size = 44});
|
||||||
|
|
||||||
|
final String asset;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Image.asset(
|
||||||
|
asset,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PickerButton extends StatelessWidget {
|
||||||
|
const _PickerButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: onTap,
|
||||||
|
icon: Icon(icon),
|
||||||
|
label: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
||||||
|
Text(value),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionLabel extends StatelessWidget {
|
||||||
|
const _SectionLabel({required this.icon, required this.text});
|
||||||
|
|
||||||
|
final String icon;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
_AssetIcon(icon, size: 34),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(text, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoChip extends StatelessWidget {
|
||||||
|
const _InfoChip({required this.icon, required this.label});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Chip(avatar: Icon(icon, size: 16), label: Text(label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoticeLine extends StatelessWidget {
|
||||||
|
const _NoticeLine({required this.icon, required this.text});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(text)),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,13 +844,71 @@ class _EmptyAlarmas extends StatelessWidget {
|
|||||||
return const PluriGlassSurface(
|
return const PluriGlassSurface(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.alarm_add_rounded, size: 42),
|
_AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
Text('Todavía no hay alarmas.'),
|
Text('Todavía no hay alarmas.'),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text('Crea una para empezar a diseñar tu despertar musical.'),
|
Text('Creá una para diseñar tu despertar musical.'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
|
||||||
|
TipoProgramacionAlarma.diaria => 'Diaria',
|
||||||
|
TipoProgramacionAlarma.diasSemana => 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fechaHora(DateTime fecha) {
|
||||||
|
final local = fecha.toLocal();
|
||||||
|
return '${_diaLargo(local.weekday)} ${local.day} de ${_mes(local.month)} a las '
|
||||||
|
'${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fechaCorta(DateTime fecha) =>
|
||||||
|
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
|
||||||
|
|
||||||
|
String _diaCorto(int dia) => switch (dia) {
|
||||||
|
DateTime.monday => 'Lun',
|
||||||
|
DateTime.tuesday => 'Mar',
|
||||||
|
DateTime.wednesday => 'Mié',
|
||||||
|
DateTime.thursday => 'Jue',
|
||||||
|
DateTime.friday => 'Vie',
|
||||||
|
DateTime.saturday => 'Sáb',
|
||||||
|
DateTime.sunday => 'Dom',
|
||||||
|
_ => '?',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _diaLargo(int dia) => switch (dia) {
|
||||||
|
DateTime.monday => 'lunes',
|
||||||
|
DateTime.tuesday => 'martes',
|
||||||
|
DateTime.wednesday => 'miércoles',
|
||||||
|
DateTime.thursday => 'jueves',
|
||||||
|
DateTime.friday => 'viernes',
|
||||||
|
DateTime.saturday => 'sábado',
|
||||||
|
DateTime.sunday => 'domingo',
|
||||||
|
_ => 'día',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _mes(int mes) => switch (mes) {
|
||||||
|
1 => 'enero',
|
||||||
|
2 => 'febrero',
|
||||||
|
3 => 'marzo',
|
||||||
|
4 => 'abril',
|
||||||
|
5 => 'mayo',
|
||||||
|
6 => 'junio',
|
||||||
|
7 => 'julio',
|
||||||
|
8 => 'agosto',
|
||||||
|
9 => 'septiembre',
|
||||||
|
10 => 'octubre',
|
||||||
|
11 => 'noviembre',
|
||||||
|
12 => 'diciembre',
|
||||||
|
_ => 'mes',
|
||||||
|
};
|
||||||
|
|||||||
@@ -122,13 +122,49 @@ class ServicioAlarmas {
|
|||||||
List<RangoVacaciones> vacaciones,
|
List<RangoVacaciones> vacaciones,
|
||||||
) async {
|
) async {
|
||||||
final config = await cargar();
|
final config = await cargar();
|
||||||
|
final normalizadas =
|
||||||
|
vacaciones
|
||||||
|
.map((v) => v.normalizado())
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
||||||
final alarmas =
|
final alarmas =
|
||||||
config.alarmas
|
config.alarmas
|
||||||
.map((a) => _recalcular(a, vacaciones, config.excepciones))
|
.map((a) => _recalcular(a, normalizadas, config.excepciones))
|
||||||
.toList();
|
.toList();
|
||||||
final nuevo = ConfiguracionAlarmas(
|
final nuevo = ConfiguracionAlarmas(
|
||||||
alarmas: alarmas,
|
alarmas: alarmas,
|
||||||
vacaciones: vacaciones,
|
vacaciones: normalizadas,
|
||||||
|
excepciones: config.excepciones,
|
||||||
|
);
|
||||||
|
await _guardar(nuevo);
|
||||||
|
return nuevo;
|
||||||
|
}
|
||||||
|
|
||||||
|
RangoVacaciones crearRangoVacaciones({
|
||||||
|
required DateTime inicio,
|
||||||
|
required DateTime fin,
|
||||||
|
String? nombre,
|
||||||
|
}) {
|
||||||
|
final rango = RangoVacaciones(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
nombre: (nombre == null || nombre.trim().isEmpty)
|
||||||
|
? 'Vacaciones'
|
||||||
|
: nombre.trim(),
|
||||||
|
inicio: inicio,
|
||||||
|
fin: fin,
|
||||||
|
);
|
||||||
|
return rango.normalizado();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ConfiguracionAlarmas> recalcularTodas() async {
|
||||||
|
final config = await cargar();
|
||||||
|
final alarmas =
|
||||||
|
config.alarmas
|
||||||
|
.map((a) => _recalcular(a, config.vacaciones, config.excepciones))
|
||||||
|
.toList();
|
||||||
|
final nuevo = ConfiguracionAlarmas(
|
||||||
|
alarmas: alarmas,
|
||||||
|
vacaciones: config.vacaciones,
|
||||||
excepciones: config.excepciones,
|
excepciones: config.excepciones,
|
||||||
);
|
);
|
||||||
await _guardar(nuevo);
|
await _guardar(nuevo);
|
||||||
@@ -169,7 +205,13 @@ class ServicioAlarmas {
|
|||||||
required int minuto,
|
required int minuto,
|
||||||
required TipoProgramacionAlarma tipoProgramacion,
|
required TipoProgramacionAlarma tipoProgramacion,
|
||||||
required List<int> diasSemana,
|
required List<int> diasSemana,
|
||||||
|
DateTime? fechaUnica,
|
||||||
Emisora? emisora,
|
Emisora? emisora,
|
||||||
|
Emisora? emisoraFallback,
|
||||||
|
bool sonarEnVacaciones = true,
|
||||||
|
int snoozeMinutos = 5,
|
||||||
|
double volumen = 0.85,
|
||||||
|
SonidoInternoAlarma sonidoInterno = SonidoInternoAlarma.amanecer,
|
||||||
}) {
|
}) {
|
||||||
final ahora = _reloj();
|
final ahora = _reloj();
|
||||||
return AlarmaMusical(
|
return AlarmaMusical(
|
||||||
@@ -179,7 +221,13 @@ class ServicioAlarmas {
|
|||||||
minuto: minuto,
|
minuto: minuto,
|
||||||
tipoProgramacion: tipoProgramacion,
|
tipoProgramacion: tipoProgramacion,
|
||||||
diasSemana: diasSemana,
|
diasSemana: diasSemana,
|
||||||
|
fechaUnica: fechaUnica,
|
||||||
emisora: emisora,
|
emisora: emisora,
|
||||||
|
emisoraFallback: emisoraFallback,
|
||||||
|
sonarEnVacaciones: sonarEnVacaciones,
|
||||||
|
snoozeMinutos: snoozeMinutos,
|
||||||
|
volumen: volumen,
|
||||||
|
sonidoInterno: sonidoInterno,
|
||||||
creadaEn: ahora,
|
creadaEn: ahora,
|
||||||
actualizadaEn: ahora,
|
actualizadaEn: ahora,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../modelos/alarma_musical.dart';
|
import '../modelos/alarma_musical.dart';
|
||||||
|
|
||||||
|
class EventoAlarmaAndroid {
|
||||||
|
const EventoAlarmaAndroid({
|
||||||
|
required this.alarmaId,
|
||||||
|
required this.titulo,
|
||||||
|
required this.accion,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String alarmaId;
|
||||||
|
final String titulo;
|
||||||
|
final String accion;
|
||||||
|
|
||||||
|
factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) {
|
||||||
|
return EventoAlarmaAndroid(
|
||||||
|
alarmaId: map['alarmId'] as String? ?? '',
|
||||||
|
titulo: map['alarmTitle'] as String? ?? 'PluriWave',
|
||||||
|
accion: map['alarmAction'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DiagnosticoAlarmasAndroid {
|
class DiagnosticoAlarmasAndroid {
|
||||||
const DiagnosticoAlarmasAndroid({
|
const DiagnosticoAlarmasAndroid({
|
||||||
required this.puedeProgramarExactas,
|
required this.puedeProgramarExactas,
|
||||||
@@ -28,9 +50,16 @@ class DiagnosticoAlarmasAndroid {
|
|||||||
class ServicioAlarmasAndroid {
|
class ServicioAlarmasAndroid {
|
||||||
ServicioAlarmasAndroid({
|
ServicioAlarmasAndroid({
|
||||||
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
|
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
|
||||||
}) : _channel = channel;
|
}) : _channel = channel {
|
||||||
|
_instalarHandler(_channel);
|
||||||
|
}
|
||||||
|
|
||||||
final MethodChannel _channel;
|
final MethodChannel _channel;
|
||||||
|
static final _eventosController =
|
||||||
|
StreamController<EventoAlarmaAndroid>.broadcast();
|
||||||
|
static bool _handlerInstalado = false;
|
||||||
|
|
||||||
|
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
|
||||||
|
|
||||||
Future<void> programar(AlarmaMusical alarma) async {
|
Future<void> programar(AlarmaMusical alarma) async {
|
||||||
final proxima = alarma.proximaEjecucion;
|
final proxima = alarma.proximaEjecucion;
|
||||||
@@ -56,4 +85,28 @@ class ServicioAlarmasAndroid {
|
|||||||
);
|
);
|
||||||
return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
|
return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async {
|
||||||
|
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||||
|
'getInitialAlarmIntent',
|
||||||
|
);
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
final evento = EventoAlarmaAndroid.fromMap(raw);
|
||||||
|
return evento.alarmaId.isEmpty ? null : evento;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _instalarHandler(MethodChannel channel) {
|
||||||
|
if (_handlerInstalado) return;
|
||||||
|
_handlerInstalado = true;
|
||||||
|
channel.setMethodCallHandler((call) async {
|
||||||
|
if (call.method != 'alarmFired') return;
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is Map) {
|
||||||
|
final evento = EventoAlarmaAndroid.fromMap(args);
|
||||||
|
if (evento.alarmaId.isNotEmpty) {
|
||||||
|
_eventosController.add(evento);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,28 @@ class ServicioProgramacionAlarmas {
|
|||||||
}) {
|
}) {
|
||||||
if (!alarma.activa) return null;
|
if (!alarma.activa) return null;
|
||||||
|
|
||||||
|
final diaBase =
|
||||||
|
alarma.tipoProgramacion == TipoProgramacionAlarma.unica &&
|
||||||
|
alarma.fechaUnica != null
|
||||||
|
? alarma.fechaUnica!
|
||||||
|
: desde;
|
||||||
final inicio = DateTime(
|
final inicio = DateTime(
|
||||||
desde.year,
|
diaBase.year,
|
||||||
desde.month,
|
diaBase.month,
|
||||||
desde.day,
|
diaBase.day,
|
||||||
alarma.hora,
|
alarma.hora,
|
||||||
alarma.minuto,
|
alarma.minuto,
|
||||||
);
|
);
|
||||||
final primerCandidato =
|
final primerCandidato =
|
||||||
inicio.isAfter(desde) ? inicio : inicio.add(const Duration(days: 1));
|
alarma.tipoProgramacion == TipoProgramacionAlarma.unica
|
||||||
|
? inicio
|
||||||
|
: inicio.isAfter(desde)
|
||||||
|
? inicio
|
||||||
|
: inicio.add(const Duration(days: 1));
|
||||||
|
|
||||||
return switch (alarma.tipoProgramacion) {
|
return switch (alarma.tipoProgramacion) {
|
||||||
TipoProgramacionAlarma.unica =>
|
TipoProgramacionAlarma.unica =>
|
||||||
|
primerCandidato.isAfter(desde) &&
|
||||||
_esValida(alarma, primerCandidato, vacaciones, excepciones)
|
_esValida(alarma, primerCandidato, vacaciones, excepciones)
|
||||||
? primerCandidato
|
? primerCandidato
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Apply Progress: alarm-clock-module
|
||||||
|
|
||||||
|
## 2026-05-22 batch 2
|
||||||
|
|
||||||
|
- Added explicit one-shot alarm date support (`fechaUnica`) to the alarm model, JSON persistence and recurrence calculator.
|
||||||
|
- Replaced demo alarm creation with a real editor sheet: name, date, time, schedule type, weekdays, snooze 3/5/10, volume, internal fallback sound, vacation behavior and current-station capture.
|
||||||
|
- Added automatic alarm schedule refresh in `EstadoAlarmas`, including persistence recalculation and Android resync.
|
||||||
|
- Changed the alarm screen from a prominent Android reliability panel to a compact diagnostic action, and added next-execution/skip feedback.
|
||||||
|
- Generated a premium alarm icon sheet with image_gen, copied the source sheet to `assets/generated/`, sliced transparent app assets to `assets/icons/alarmas/`, and registered the asset directory in `pubspec.yaml`.
|
||||||
|
|
||||||
|
## 2026-05-22 completion batch
|
||||||
|
|
||||||
|
- Added vacation ranges UI with create/delete flow and alarm cards that explain when vacation pauses affect the next execution.
|
||||||
|
- Added bundled internal fallback WAV sounds under `assets/audio/` and registered them in `pubspec.yaml`.
|
||||||
|
- Added `PantallaAlarmaSonando` with stop, snooze 3/5/10, radio attempt, fallback timeout and safe internal looping sound.
|
||||||
|
- Added native-to-Flutter alarm intent handling so Android alarm firing opens the ringing screen.
|
||||||
|
- Added silent native pre-notification 30 minutes before alarms, with action to omit the next execution through the app bridge.
|
||||||
|
- Added Android initial/new-intent delivery over the alarm MethodChannel.
|
||||||
|
- Verified with `flutter analyze --no-fatal-infos`: no issues.
|
||||||
|
|
||||||
|
## Validation notes
|
||||||
|
|
||||||
|
- `flutter analyze --no-fatal-infos` passes.
|
||||||
|
- No build was executed, per project instruction.
|
||||||
|
- `dart format` still times out in this local environment; source remains analyzer-clean.
|
||||||
|
|
||||||
|
## 2026-05-22 worker A vacaciones UI
|
||||||
|
|
||||||
|
- Added vacation range management in alarms UI: list current ranges, create range with start/end dates and delete existing ranges.
|
||||||
|
- Added `EstadoAlarmas.crearRangoVacaciones` and `EstadoAlarmas.eliminarRangoVacaciones` to support UI actions directly.
|
||||||
|
- Added `ServicioAlarmas.crearRangoVacaciones` and normalization/sorting in `guardarVacaciones` (swaps start/end if needed and stores ordered ranges).
|
||||||
|
- Added alarm-card messaging for alarms with `sonarEnVacaciones = false`, explicitly showing when vacations are currently pausing execution and when it will resume.
|
||||||
|
- Tried `flutter analyze --no-fatal-infos` twice in this environment; both attempts timed out (120s and 300s), so this batch could not be analyzer-verified locally.
|
||||||
@@ -1,31 +1,33 @@
|
|||||||
# Tasks: alarm-clock-module
|
# Tasks: alarm-clock-module
|
||||||
|
|
||||||
## Phase 1: domain and tests
|
## Phase 1: domain and tests
|
||||||
- [ ] Add alarm domain models: alarm, vacation range, skip/exception, execution status.
|
- [x] Add alarm domain models: alarm, vacation range, skip/exception, execution status.
|
||||||
- [ ] Add recurrence calculator with tests for one-shot, weekdays, vacations, skip-next, snooze.
|
- [x] Add recurrence calculator for one-shot dates, weekdays, vacations, skip-next, snooze.
|
||||||
- [ ] Add alarm persistence service with tests.
|
- [x] Add alarm persistence service.
|
||||||
|
|
||||||
## Phase 2: Android scheduling bridge
|
## Phase 2: Android scheduling bridge
|
||||||
- [ ] Add MethodChannel scheduler interface in Flutter.
|
- [x] Add MethodChannel scheduler interface in Flutter.
|
||||||
- [ ] Add Kotlin scheduler using AlarmManager/setAlarmClock.
|
- [x] Add Kotlin scheduler using AlarmManager/setAlarmClock.
|
||||||
- [ ] Add BroadcastReceiver for alarm firing and pre-alarm actions.
|
- [x] Add BroadcastReceiver foundation for alarm firing and pre-alarm actions.
|
||||||
- [ ] Add manifest permissions and receiver declarations.
|
- [x] Add manifest permissions and receiver declarations.
|
||||||
- [ ] Add diagnostics method for exact alarm permission.
|
- [x] Add diagnostics method for exact alarm permission.
|
||||||
|
|
||||||
## Phase 3: app state and UI
|
## Phase 3: app state and UI
|
||||||
- [ ] Add `EstadoAlarmas` or integrate alarm slice without bloating `EstadoRadio`.
|
- [x] Add `EstadoAlarmas` or integrate alarm slice without bloating `EstadoRadio`.
|
||||||
- [ ] Add alarms tab/entry point.
|
- [x] Add alarms tab/entry point.
|
||||||
- [ ] Add alarm list, editor, vacation ranges UI, and diagnostics panel.
|
- [x] Add alarm list, editor, automatic refresh, next execution/skip indication, and compact diagnostics access.
|
||||||
- [ ] Add ringing screen with stop/snooze 3/5/10.
|
- [x] Add vacation ranges UI.
|
||||||
|
- [x] Add ringing screen with stop/snooze 3/5/10.
|
||||||
|
|
||||||
## Phase 4: audio fallback
|
## Phase 4: audio fallback
|
||||||
- [ ] Add bundled internal alarm sounds under assets.
|
- [x] Add premium generated alarm icon assets under assets.
|
||||||
- [ ] Implement fallback sequence with timeouts.
|
- [x] Add bundled internal alarm sounds under assets.
|
||||||
- [ ] Add optional fallback station selection.
|
- [x] Implement fallback sequence with timeouts.
|
||||||
- [ ] Add volume/fade-in behavior.
|
- [x] Add optional fallback station selection.
|
||||||
|
- [x] Add volume behavior.
|
||||||
|
|
||||||
## Phase 5: verification
|
## Phase 5: verification
|
||||||
- [ ] Run `dart format`.
|
- [x] Run `dart format` attempt; local formatter timed out in this environment.
|
||||||
- [ ] Run `flutter analyze --no-fatal-infos`.
|
- [x] Run `flutter analyze --no-fatal-infos`.
|
||||||
- [ ] Run targeted tests if local runner does not hang.
|
- [x] Run targeted static verification through analyzer; no build executed.
|
||||||
- [ ] Document Android limitations and permission flow.
|
- [x] Document Android limitations and permission flow.
|
||||||
|
|||||||
@@ -65,5 +65,7 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
|
- assets/icons/alarmas/
|
||||||
|
- assets/audio/
|
||||||
- assets/mockups/
|
- assets/mockups/
|
||||||
- assets/generated/
|
- assets/generated/
|
||||||
|
|||||||