diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index 1871ea2..9fda42c 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -5,6 +5,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import androidx.core.app.NotificationManagerCompat class AlarmScheduler(private val context: Context) { private val alarmManager = @@ -28,6 +29,8 @@ class AlarmScheduler(private val context: Context) { Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP 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 ) @@ -66,6 +69,9 @@ class AlarmScheduler(private val context: Context) { ) ?: continue ) } + NotificationManagerCompat.from(context).cancel( + PluriWaveAlarmReceiver.notificationIdForAlarm(id) + ) } fun canScheduleExactAlarms(): Boolean { diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 390171c..a0f71d3 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -1,11 +1,13 @@ package es.freetimelab.pluriwave import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.media.audiofx.Visualizer import android.os.Build import android.os.Handler import android.os.Looper +import androidx.core.app.NotificationManagerCompat import com.ryanheise.audioservice.AudioServiceActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel @@ -18,6 +20,7 @@ class MainActivity : AudioServiceActivity() { private var visualizer: Visualizer? = null private var pendingSink: EventChannel.EventSink? = null private var pendingArgs: Map<*, *>? = null + private var alarmMethodChannel: MethodChannel? = null private val mainHandler = Handler(Looper.getMainLooper()) override fun configureFlutterEngine(flutterEngine: FlutterEngine) { @@ -40,10 +43,11 @@ class MainActivity : AudioServiceActivity() { }) val alarmScheduler = AlarmScheduler(this) - MethodChannel( + alarmMethodChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, alarmChannel - ).setMethodCallHandler { call, result -> + ) + alarmMethodChannel?.setMethodCallHandler { call, result -> when (call.method) { "scheduleAlarm" -> { val id = call.argument("id") @@ -70,17 +74,45 @@ class MainActivity : AudioServiceActivity() { result.success( mapOf( "canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(), - "notificationsEnabled" to true, + "notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(), "manufacturer" to Build.MANUFACTURER, "sdkInt" to Build.VERSION.SDK_INT ) ) } + "getInitialAlarmIntent" -> { + result.success(alarmPayload(intent)) + intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION) + } 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 { + 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() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && checkSelfPermission(Manifest.permission.RECORD_AUDIO) diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt index dca42a5..3e7d40f 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -1,8 +1,14 @@ package es.freetimelab.pluriwave +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat class PluriWaveAlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -20,21 +26,91 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { context.startActivity(launch) } ACTION_PRE_NOTICE -> { - // MVP: native delivery exists; Flutter will own skip-next UX. - // Next batch: notification channel + action button. + showPreNoticeNotification(context, alarmId, title) } 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 { + const val CHANNEL_ID = "pluriwave_alarm_pre_notice" 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" + + fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7 } } diff --git a/assets/audio/alarm_amanecer.wav b/assets/audio/alarm_amanecer.wav new file mode 100644 index 0000000..2f77115 Binary files /dev/null and b/assets/audio/alarm_amanecer.wav differ diff --git a/assets/audio/alarm_campana_suave.wav b/assets/audio/alarm_campana_suave.wav new file mode 100644 index 0000000..9848922 Binary files /dev/null and b/assets/audio/alarm_campana_suave.wav differ diff --git a/assets/audio/alarm_pulso_digital.wav b/assets/audio/alarm_pulso_digital.wav new file mode 100644 index 0000000..75a21f4 Binary files /dev/null and b/assets/audio/alarm_pulso_digital.wav differ diff --git a/assets/generated/pluriwave_alarm_icon_sheet.png b/assets/generated/pluriwave_alarm_icon_sheet.png new file mode 100644 index 0000000..dd2d931 Binary files /dev/null and b/assets/generated/pluriwave_alarm_icon_sheet.png differ diff --git a/assets/icons/alarmas/alarm_music.png b/assets/icons/alarmas/alarm_music.png new file mode 100644 index 0000000..aab4954 Binary files /dev/null and b/assets/icons/alarmas/alarm_music.png differ diff --git a/assets/icons/alarmas/android_reliability.png b/assets/icons/alarmas/android_reliability.png new file mode 100644 index 0000000..02692ca Binary files /dev/null and b/assets/icons/alarmas/android_reliability.png differ diff --git a/assets/icons/alarmas/fallback_sound.png b/assets/icons/alarmas/fallback_sound.png new file mode 100644 index 0000000..ab6e800 Binary files /dev/null and b/assets/icons/alarmas/fallback_sound.png differ diff --git a/assets/icons/alarmas/skip_next.png b/assets/icons/alarmas/skip_next.png new file mode 100644 index 0000000..33d62d2 Binary files /dev/null and b/assets/icons/alarmas/skip_next.png differ diff --git a/assets/icons/alarmas/snooze_wave.png b/assets/icons/alarmas/snooze_wave.png new file mode 100644 index 0000000..1cfbf24 Binary files /dev/null and b/assets/icons/alarmas/snooze_wave.png differ diff --git a/assets/icons/alarmas/vacation_wave.png b/assets/icons/alarmas/vacation_wave.png new file mode 100644 index 0000000..6bc8889 Binary files /dev/null and b/assets/icons/alarmas/vacation_wave.png differ diff --git a/docs/alarmas-android.md b/docs/alarmas-android.md new file mode 100644 index 0000000..7d93bce --- /dev/null +++ b/docs/alarmas-android.md @@ -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. diff --git a/lib/app.dart b/lib/app.dart index 978dc59..d27ca58 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'estado/estado_radio.dart'; import 'estado/estado_alarmas.dart'; +import 'modelos/alarma_musical.dart'; import 'pantallas/pantalla_alarmas.dart'; +import 'pantallas/pantalla_alarma_sonando.dart'; import 'pantallas/pantalla_inicio.dart'; import 'pantallas/pantalla_buscar.dart'; import 'pantallas/pantalla_favoritos.dart'; @@ -13,6 +15,7 @@ import 'widgets/pluri_glass_surface.dart'; import 'widgets/pluri_icon.dart'; import 'widgets/pluri_wave_scaffold.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart'; +import 'servicios/servicio_alarmas_android.dart'; class PluriWaveApp extends StatelessWidget { const PluriWaveApp({super.key}); @@ -46,7 +49,9 @@ class _PaginaPrincipal extends StatefulWidget { class _PaginaPrincipalState extends State<_PaginaPrincipal> { int _indice = 0; StreamSubscription? _errorSubscription; + StreamSubscription? _alarmaSubscription; EstadoRadio? _estadoSuscrito; + bool _alarmaInicialProcesada = false; static const _paginas = [ PantallaInicio(), @@ -118,11 +123,22 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { ), ); }); + + final alarmas = context.read(); + _alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) { + if (!mounted) return; + _abrirAlarmaSonando(evento); + }); + if (!_alarmaInicialProcesada) { + _alarmaInicialProcesada = true; + unawaited(_procesarAlarmaInicial(alarmas)); + } } @override void dispose() { _errorSubscription?.cancel(); + _alarmaSubscription?.cancel(); super.dispose(); } @@ -165,6 +181,47 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { ); } + Future _procesarAlarmaInicial(EstadoAlarmas alarmas) async { + final evento = await alarmas.android.obtenerEventoInicial(); + if (evento != null && mounted) { + await _abrirAlarmaSonando(evento); + } + } + + Future _abrirAlarmaSonando(EventoAlarmaAndroid evento) async { + final estado = context.read(); + 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( + builder: (_) => PantallaAlarmaSonando(alarma: alarma!), + fullscreenDialog: true, + ), + ); + } + void _mostrarTimerDialog(BuildContext context) { final estado = context.read(); showModalBottomSheet( diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index a18a79a..9f5dbb4 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import '../modelos/alarma_musical.dart'; @@ -21,12 +23,15 @@ class EstadoAlarmas extends ChangeNotifier { List _alarmas = []; List _vacaciones = []; + List _excepciones = []; DiagnosticoAlarmasAndroid? _diagnostico; + Timer? _refresco; bool _cargando = false; String? _error; List get alarmas => List.unmodifiable(_alarmas); List get vacaciones => List.unmodifiable(_vacaciones); + List get excepciones => List.unmodifiable(_excepciones); DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico; bool get cargando => _cargando; String? get error => _error; @@ -47,6 +52,7 @@ class EstadoAlarmas extends ChangeNotifier { _aplicar(config); await _sincronizarTodas(); await cargarDiagnostico(); + _activarRefresco(); } catch (e) { _error = 'No se pudieron cargar las alarmas: $e'; } finally { @@ -62,6 +68,13 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + Future refrescarProgramacion() async { + final config = await servicio.recalcularTodas(); + _aplicar(config); + await _sincronizarTodas(); + notifyListeners(); + } + Future eliminarAlarma(String id) async { final config = await servicio.eliminarAlarma(id); _aplicar(config); @@ -96,6 +109,32 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + Future posponerAlarma(AlarmaMusical alarma, int minutos) async { + final proxima = DateTime.now().add(Duration(minutes: minutos)); + await android.programar(alarma.copyWith(proximaEjecucion: proxima)); + } + + Future finalizarEjecucion(String alarmaId) async { + await refrescarProgramacion(); + } + + Future crearRangoVacaciones(RangoVacaciones rango) async { + final nuevos = [..._vacaciones, rango]; + await guardarVacaciones(nuevos); + } + + Future 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 cargarDiagnostico() async { try { _diagnostico = await android.diagnostico(); @@ -114,5 +153,19 @@ class EstadoAlarmas extends ChangeNotifier { void _aplicar(ConfiguracionAlarmas config) { _alarmas = config.alarmas; _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(); } } diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index dfd1acf..bc8faca 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -12,6 +12,7 @@ class AlarmaMusical { required this.minuto, required this.tipoProgramacion, required this.diasSemana, + this.fechaUnica, this.emisora, this.emisoraFallback, this.activa = true, @@ -31,6 +32,7 @@ class AlarmaMusical { final int minuto; final TipoProgramacionAlarma tipoProgramacion; final List diasSemana; + final DateTime? fechaUnica; final Emisora? emisora; final Emisora? emisoraFallback; final bool sonarEnVacaciones; @@ -49,6 +51,8 @@ class AlarmaMusical { int? minuto, TipoProgramacionAlarma? tipoProgramacion, List? diasSemana, + DateTime? fechaUnica, + bool limpiarFechaUnica = false, Emisora? emisora, Emisora? emisoraFallback, bool? sonarEnVacaciones, @@ -67,6 +71,7 @@ class AlarmaMusical { minuto: minuto ?? this.minuto, tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion, diasSemana: diasSemana ?? this.diasSemana, + fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica, emisora: emisora ?? this.emisora, emisoraFallback: emisoraFallback ?? this.emisoraFallback, sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones, @@ -87,6 +92,7 @@ class AlarmaMusical { 'minuto': minuto, 'tipoProgramacion': tipoProgramacion.name, 'diasSemana': diasSemana, + 'fechaUnica': fechaUnica?.toIso8601String(), 'emisora': emisora?.toMap(), 'emisoraFallback': emisoraFallback?.toMap(), 'sonarEnVacaciones': sonarEnVacaciones, @@ -115,6 +121,7 @@ class AlarmaMusical { .whereType() .where((d) => d >= DateTime.monday && d <= DateTime.sunday) .toList(), + fechaUnica: _dateFromJson(json['fechaUnica']), emisora: _emisoraFromJson(json['emisora']), emisoraFallback: _emisoraFromJson(json['emisoraFallback']), sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true, @@ -166,13 +173,27 @@ class RangoVacaciones { final DateTime fin; 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) { 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); + final desde = inicioDia; + final hasta = finDia; 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 toJson() => { 'id': id, 'nombre': nombre, diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart new file mode 100644 index 0000000..b6cae5d --- /dev/null +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -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 createState() => _PantallaAlarmaSonandoState(); +} + +class _PantallaAlarmaSonandoState extends State { + final AudioPlayer _fallbackPlayer = AudioPlayer(); + StreamSubscription? _estadoSub; + Timer? _fallbackTimer; + bool _fallbackActivo = false; + bool _radioIntentada = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma()); + } + + Future _iniciarAlarma() async { + final radio = context.read(); + 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 _iniciarFallback() async { + if (_fallbackActivo) return; + _fallbackActivo = true; + await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno)); + await _fallbackPlayer.play(); + if (mounted) setState(() {}); + } + + Future _detener() async { + final radio = context.read(); + final alarmas = context.read(); + 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 _posponer(int minutos) async { + final radio = context.read(); + final alarmas = context.read(); + 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')}'; diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 40f60a3..09bddfc 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -1,8 +1,10 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../estado/estado_alarmas.dart'; +import '../estado/estado_radio.dart'; import '../modelos/alarma_musical.dart'; +import '../modelos/emisora.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_premium_widgets.dart'; @@ -14,124 +16,97 @@ class PantallaAlarmas extends StatelessWidget { Widget build(BuildContext context) { final estado = context.watch(); - return ListView( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 124), - children: [ - PluriScreenHeader( - title: 'Alarmas musicales', - subtitle: - 'Despertador con radio, vacaciones, aviso previo y fallbacks seguros.', - glyph: PluriIconGlyph.alarm, - primaryActionLabel: 'Nueva alarma', - onPrimaryAction: () => _crearDemo(context), - trailing: PluriStatusPill( - icon: Icons.alarm_on_rounded, - label: '${estado.alarmas.length} alarmas', + return RefreshIndicator( + onRefresh: estado.refrescarProgramacion, + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 124), + children: [ + PluriScreenHeader( + title: 'Despertar musical', + subtitle: + 'Alarmas con radio, sonido seguro, vacaciones inteligentes y próxima ejecución siempre visible.', + glyph: PluriIconGlyph.alarm, + primaryActionLabel: 'Crear alarma', + onPrimaryAction: () => _abrirEditor(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), - ], - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _PanelProximaAlarma(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: 12), + ], + _PanelVacaciones(estado: estado), + const SizedBox(height: 12), + _AccesoDiagnostico(estado: estado), + ], + ), ), - ), - ], + ], + ), ); } - Future _crearDemo(BuildContext context) async { - final estado = context.read(); - final ahora = TimeOfDay.now(); - final alarma = estado.servicio.crearAlarma( - nombre: 'Despertador musical', - hora: ahora.hour, - minuto: (ahora.minute + 2) % 60, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: const [], + Future _abrirEditor(BuildContext context, {AlarmaMusical? alarma}) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => _EditorAlarmaSheet(alarma: alarma), ); - await estado.guardarAlarma(alarma); } } -class _DiagnosticoAlarmas extends StatelessWidget { - const _DiagnosticoAlarmas({required this.estado}); +class _PanelProximaAlarma extends StatelessWidget { + const _PanelProximaAlarma({required this.estado}); final EstadoAlarmas estado; @override Widget build(BuildContext context) { - final diag = estado.diagnostico; + final proxima = estado.proximaAlarma; return PluriGlassSurface( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28), + child: Row( 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, + const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 72), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + proxima == null ? 'Sin alarmas activas' : 'Próxima alarma', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + proxima == null + ? 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.' + : '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}', + ), + ], ), + ), ], ), ); } } -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}); @@ -139,47 +114,119 @@ class _TarjetaAlarma extends StatelessWidget { @override Widget build(BuildContext context) { - final estado = context.read(); + final estado = context.watch(); + final excepcion = estado.ultimaExcepcionPara(alarma.id); + final mensajeVacaciones = _mensajeVacaciones(estado.vacaciones); return PluriGlassSurface( + glowColor: const Color(0xFF22D3EE).withValues(alpha: 0.22), 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), + Row( + 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, + onChanged: (value) => estado.cambiarActiva(alarma, value), + ), + ], ), + const SizedBox(height: 12), 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', - ), + _InfoChip(icon: Icons.repeat_rounded, label: _programacion(alarma)), + _InfoChip(icon: Icons.snooze_rounded, label: '${alarma.snoozeMinutos} min'), + _InfoChip( + icon: Icons.beach_access_rounded, + label: alarma.sonarEnVacaciones + ? 'Suena en vacaciones' + : 'Pausa en vacaciones', + ), + _InfoChip( + icon: Icons.volume_up_rounded, + label: '${(alarma.volumen * 100).round()}%', ), ], ), - if (alarma.proximaEjecucion != null) ...[ - const SizedBox(height: 8), - Text('Próxima: ${alarma.proximaEjecucion!.toLocal()}'), + const SizedBox(height: 12), + if (alarma.proximaEjecucion != null) + _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( children: [ + TextButton.icon( + icon: const Icon(Icons.edit_rounded), + label: const Text('Editar'), + onPressed: () => _abrirEditor(context, alarma: alarma), + ), TextButton.icon( icon: const Icon(Icons.skip_next_rounded), - label: const Text('Saltar próxima'), - onPressed: () => estado.saltarProxima(alarma.id), + label: const Text('Omitir siguiente'), + onPressed: alarma.proximaEjecucion == null + ? null + : () async { + await estado.saltarProxima(alarma.id); + if (context.mounted) { + final alarmas = + context.read().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(), IconButton( @@ -194,16 +241,598 @@ class _TarjetaAlarma extends StatelessWidget { ); } - String _hora(AlarmaMusical alarma) => - '${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}'; + String? _mensajeVacaciones(List vacaciones) { + 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) { - return switch (alarma.tipoProgramacion) { - TipoProgramacionAlarma.unica => 'Una vez', - TipoProgramacionAlarma.diaria => 'Diaria', - TipoProgramacionAlarma.diasSemana => - 'Días: ${alarma.diasSemana.join(', ')}', - }; + void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + 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 _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 []}; + _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(); + 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( + 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( + 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( + 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 _elegirHora() async { + final nueva = await showTimePicker(context: context, initialTime: _hora); + if (nueva != null) setState(() => _hora = nueva); + } + + Future _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 _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(); + 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 _abrirAlta(BuildContext context) async { + await showModalBottomSheet( + 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 _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 _guardar() async { + final estado = context.read(); + 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( child: Column( children: [ - Icon(Icons.alarm_add_rounded, size: 42), + _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92), SizedBox(height: 12), Text('Todavía no hay alarmas.'), 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', + }; diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 77276a0..b412511 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -122,13 +122,49 @@ class ServicioAlarmas { List vacaciones, ) async { final config = await cargar(); + final normalizadas = + vacaciones + .map((v) => v.normalizado()) + .toList() + ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); final alarmas = config.alarmas - .map((a) => _recalcular(a, vacaciones, config.excepciones)) + .map((a) => _recalcular(a, normalizadas, config.excepciones)) .toList(); final nuevo = ConfiguracionAlarmas( 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 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, ); await _guardar(nuevo); @@ -169,7 +205,13 @@ class ServicioAlarmas { required int minuto, required TipoProgramacionAlarma tipoProgramacion, required List diasSemana, + DateTime? fechaUnica, Emisora? emisora, + Emisora? emisoraFallback, + bool sonarEnVacaciones = true, + int snoozeMinutos = 5, + double volumen = 0.85, + SonidoInternoAlarma sonidoInterno = SonidoInternoAlarma.amanecer, }) { final ahora = _reloj(); return AlarmaMusical( @@ -179,7 +221,13 @@ class ServicioAlarmas { minuto: minuto, tipoProgramacion: tipoProgramacion, diasSemana: diasSemana, + fechaUnica: fechaUnica, emisora: emisora, + emisoraFallback: emisoraFallback, + sonarEnVacaciones: sonarEnVacaciones, + snoozeMinutos: snoozeMinutos, + volumen: volumen, + sonidoInterno: sonidoInterno, creadaEn: ahora, actualizadaEn: ahora, ); diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index e85d68e..68739e5 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -1,7 +1,29 @@ +import 'dart:async'; + import 'package:flutter/services.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 map) { + return EventoAlarmaAndroid( + alarmaId: map['alarmId'] as String? ?? '', + titulo: map['alarmTitle'] as String? ?? 'PluriWave', + accion: map['alarmAction'] as String? ?? '', + ); + } +} + class DiagnosticoAlarmasAndroid { const DiagnosticoAlarmasAndroid({ required this.puedeProgramarExactas, @@ -28,9 +50,16 @@ class DiagnosticoAlarmasAndroid { class ServicioAlarmasAndroid { ServicioAlarmasAndroid({ MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'), - }) : _channel = channel; + }) : _channel = channel { + _instalarHandler(_channel); + } final MethodChannel _channel; + static final _eventosController = + StreamController.broadcast(); + static bool _handlerInstalado = false; + + Stream get eventosAlarma => _eventosController.stream; Future programar(AlarmaMusical alarma) async { final proxima = alarma.proximaEjecucion; @@ -56,4 +85,28 @@ class ServicioAlarmasAndroid { ); return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {}); } + + Future obtenerEventoInicial() async { + final raw = await _channel.invokeMethod>( + '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); + } + } + }); + } } diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index 54a7073..725bff4 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -9,19 +9,29 @@ class ServicioProgramacionAlarmas { }) { if (!alarma.activa) return null; + final diaBase = + alarma.tipoProgramacion == TipoProgramacionAlarma.unica && + alarma.fechaUnica != null + ? alarma.fechaUnica! + : desde; final inicio = DateTime( - desde.year, - desde.month, - desde.day, + diaBase.year, + diaBase.month, + diaBase.day, alarma.hora, alarma.minuto, ); 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) { TipoProgramacionAlarma.unica => - _esValida(alarma, primerCandidato, vacaciones, excepciones) + primerCandidato.isAfter(desde) && + _esValida(alarma, primerCandidato, vacaciones, excepciones) ? primerCandidato : null, TipoProgramacionAlarma.diaria => _buscarDiaria( diff --git a/openspec/changes/alarm-clock-module/apply-progress.md b/openspec/changes/alarm-clock-module/apply-progress.md new file mode 100644 index 0000000..29f81ea --- /dev/null +++ b/openspec/changes/alarm-clock-module/apply-progress.md @@ -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. diff --git a/openspec/changes/alarm-clock-module/tasks.md b/openspec/changes/alarm-clock-module/tasks.md index 0cf1b7f..73ab4a5 100644 --- a/openspec/changes/alarm-clock-module/tasks.md +++ b/openspec/changes/alarm-clock-module/tasks.md @@ -1,31 +1,33 @@ # 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. +- [x] Add alarm domain models: alarm, vacation range, skip/exception, execution status. +- [x] Add recurrence calculator for one-shot dates, weekdays, vacations, skip-next, snooze. +- [x] Add alarm persistence service. ## 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. +- [x] Add MethodChannel scheduler interface in Flutter. +- [x] Add Kotlin scheduler using AlarmManager/setAlarmClock. +- [x] Add BroadcastReceiver foundation for alarm firing and pre-alarm actions. +- [x] Add manifest permissions and receiver declarations. +- [x] 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. +- [x] Add `EstadoAlarmas` or integrate alarm slice without bloating `EstadoRadio`. +- [x] Add alarms tab/entry point. +- [x] Add alarm list, editor, automatic refresh, next execution/skip indication, and compact diagnostics access. +- [x] Add vacation ranges UI. +- [x] 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. +- [x] Add premium generated alarm icon assets under assets. +- [x] Add bundled internal alarm sounds under assets. +- [x] Implement fallback sequence with timeouts. +- [x] Add optional fallback station selection. +- [x] Add volume 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. +- [x] Run `dart format` attempt; local formatter timed out in this environment. +- [x] Run `flutter analyze --no-fatal-infos`. +- [x] Run targeted static verification through analyzer; no build executed. +- [x] Document Android limitations and permission flow. diff --git a/pubspec.yaml b/pubspec.yaml index 20de5ec..54e14df 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,5 +65,7 @@ flutter: assets: - assets/images/ - assets/icons/ + - assets/icons/alarmas/ + - assets/audio/ - assets/mockups/ - assets/generated/