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 '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../servicios/servicio_audio.dart'; import '../tema/pluri_animate.dart'; import '../tema/pluriwave_theme.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_wave_scaffold.dart'; class PantallaAlarmaSonando extends StatefulWidget { const PantallaAlarmaSonando({ super.key, required this.alarma, this.audioPrearrancado = false, }); final AlarmaMusical alarma; final bool audioPrearrancado; @override State createState() => _PantallaAlarmaSonandoState(); } class _PantallaAlarmaSonandoState extends State { static const _volumenInicialFadeIn = 0.05; static const _fadeStep = Duration(milliseconds: 250); final AudioPlayer _fallbackPlayer = AudioPlayer(); StreamSubscription? _estadoSub; Timer? _fallbackTimer; Timer? _fadeInTimer; bool _fallbackActivo = false; bool _radioIntentada = false; bool _audioFlutterConfirmado = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma()); } Future _iniciarAlarma() async { final radio = context.read(); await _fallbackPlayer.setVolume(_volumenInicialFadeIn); await _fallbackPlayer.setLoopMode(LoopMode.one); final emisora = widget.alarma.emisora; if (emisora == null) { await _iniciarFallback(); return; } _radioIntentada = true; await radio.audio.setVolumen(_volumenInicialFadeIn); if (!widget.audioPrearrancado) { unawaited(radio.reproducir(emisora)); } _iniciarFadeIn(); // S7-R4 boundary: only `reproduciendo` cancels the fallback timer — // `reconectando`/`cargando` do NOT count as playing, so the 12-second // fallback below stays authoritative during the alarm ring. Waking the // user reliably beats reconnect persistence: if the radio is still // retrying when the timer fires, the bundled WAV takes over. _estadoSub = radio.estadoStream.listen((estado) { if (estado == EstadoReproduccion.reproduciendo && mounted) { _fallbackTimer?.cancel(); _confirmarAudioFlutterListo(); } if (estado == EstadoReproduccion.error && mounted) { _iniciarFallback(); } }); _fallbackTimer = Timer(const Duration(seconds: 12), () { if (mounted) _iniciarFallback(); }); if (widget.audioPrearrancado && radio.audio.estaSonando) { _fallbackTimer?.cancel(); } } Future _iniciarFallback() async { if (_fallbackActivo) return; _fallbackActivo = true; await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno)); await _fallbackPlayer.play(); _iniciarFadeIn(); await _confirmarAudioFlutterListo(); if (mounted) setState(() {}); } void _iniciarFadeIn() { _fadeInTimer?.cancel(); final volumenObjetivo = widget.alarma.volumen.clamp(0.0, 1.0); final inicio = _volumenInicialFadeIn.clamp(0.0, volumenObjetivo); final segundosFade = widget.alarma.fadeInSegundos.clamp(0, 60); if (segundosFade <= 0 || volumenObjetivo <= inicio) { unawaited(_aplicarVolumenGlobal(volumenObjetivo)); return; } final duracionTotalMs = segundosFade * 1000; final pasos = (duracionTotalMs / _fadeStep.inMilliseconds).ceil(); var pasoActual = 0; _fadeInTimer = Timer.periodic(_fadeStep, (timer) { if (!mounted) { timer.cancel(); return; } pasoActual++; final t = (pasoActual / pasos).clamp(0.0, 1.0); final volumenActual = inicio + (volumenObjetivo - inicio) * t; unawaited(_aplicarVolumenGlobal(volumenActual)); if (t >= 1) timer.cancel(); }); } Future _aplicarVolumenGlobal(double volumen) async { if (!mounted) return; final radio = context.read(); await radio.audio.setVolumen(volumen.clamp(0.0, 1.0)); await _fallbackPlayer.setVolume(volumen.clamp(0.0, 1.0)); } Future _confirmarAudioFlutterListo() async { if (_audioFlutterConfirmado) return; _audioFlutterConfirmado = true; await context.read().android.confirmarAudioFlutter( widget.alarma.id, ); } /// Shared local-audio teardown for stop and snooze (Design 2.3): the Dart /// fallback player and fade timer MUST die before the alarm is re-programmed /// natively, otherwise the local fallback keeps looping after snooze. Future _liberarAudioLocal() async { _fallbackTimer?.cancel(); _fadeInTimer?.cancel(); // cancel() detiene la entrega de eventos de forma sincrona; no se espera // su Future porque puede no resolverse hasta que el stream se cierre. unawaited(_estadoSub?.cancel()); _estadoSub = null; await _fallbackPlayer.stop(); } Future _detener() async { final radio = context.read(); final alarmas = context.read(); final navigator = Navigator.of(context); await _liberarAudioLocal(); await radio.audio.pausar(); await alarmas.finalizarEjecucion(widget.alarma.id); if (mounted) navigator.pop(); } /// Flutter-first snooze (S2-R1): tears down local audio, then routes /// through the canonical EstadoAlarmas.posponerAlarma, which hides the /// native notification (same stop path as dismiss) and re-programs Android. Future _posponer(int minutos) async { final radio = context.read(); final alarmas = context.read(); final navigator = Navigator.of(context); await _liberarAudioLocal(); await radio.audio.pausar(); await alarmas.posponerAlarma(widget.alarma, minutos); if (mounted) navigator.pop(); } List _opcionesSnooze() { final opciones = {3, 5, 10}; final propio = widget.alarma.snoozeMinutos; if (propio > 0) opciones.add(propio); return opciones.toList()..sort(); } @override void dispose() { _fallbackTimer?.cancel(); _fadeInTimer?.cancel(); _estadoSub?.cancel(); _fallbackPlayer.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final alarma = widget.alarma; final l10n = AppLocalizations.of(context); final tokens = context.pluriTokens; // Cold-GPU note (Design 2.4): PluriGlassSurface uses a BackdropFilter and // the first frame after a screen-off FSI wake can stutter. The blur sigma // is capped here, and reduced-motion users skip the entry animation // entirely via pluriFadeIn. return PluriWaveScaffold( body: SafeArea( child: Padding( padding: const EdgeInsets.all(20), child: Center( child: PluriGlassSurface( borderRadius: BorderRadius.circular(32), padding: const EdgeInsets.all(24), blurSigma: 10, glowColor: tokens.warmCoral.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( localizedAlarmName(l10n, alarma.nombre), textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( _fallbackActivo ? l10n.alarmRingingFallbackActive : _radioIntentada ? l10n.alarmRingingTryingStation : l10n.alarmRingingPreparingFallback, textAlign: TextAlign.center, ), const SizedBox(height: 22), Text( l10n.snoozeAction, style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ for (final minutos in _opcionesSnooze()) OutlinedButton.icon( onPressed: () => _posponer(minutos), icon: const Icon(Icons.snooze_rounded), label: Text(l10n.alarmSnoozeOptionLabel(minutos)), ), ], ), const SizedBox(height: 14), FilledButton.icon( onPressed: _detener, icon: const Icon(Icons.stop_rounded), label: Text(l10n.stopAlarmAction), ), ], ), ).pluriFadeIn(context), ), ), ), ); } } 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')}';