import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.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; bool _volumenMediaRestaurado = false; // Captured while mounted: dispose() also restores the media volume, and by // then the element is defunct, so context.read() would throw there. late final EstadoAlarmas _estadoAlarmas; @override void initState() { super.initState(); _estadoAlarmas = context.read(); 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)); } // 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(); }); // Pre-started audio can reach `reproduciendo` BEFORE the listener above // subscribes (app.dart starts the station before pushing this screen), // in which case no further state event ever arrives. The handoff // confirmation and the fade-in must not depend on catching that // already-missed event, so this branch confirms explicitly too // (idempotent — the listener firing as well is harmless). if (widget.audioPrearrancado && radio.audio.estaSonando) { _fallbackTimer?.cancel(); await _confirmarAudioFlutterListo(); } } Future _iniciarFallback() async { if (_fallbackActivo) return; _fallbackActivo = true; await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno)); await _fallbackPlayer.play(); 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; } // Re-impose the ramp's start volume immediately: the first periodic tick // only lands after _fadeStep, and by now the player may have been // recreated or re-leveled since the pre-start set it to 5%. unawaited(_aplicarVolumenGlobal(inicio)); 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)); } /// Confirms the native-to-Flutter audio handoff at most once per screen /// instance, then starts the single audible Dart fade-in ramp (Slice 3: /// fade-in dedup at handoff). The native ramp /// (`PluriWaveAlarmService.startFadeIn`) owns audio until this /// confirmation lands; starting the Dart ramp any earlier would /// interleave both ramps and produce an audible jump/reset. If the /// native confirmation channel call fails (dead or never-there native /// side), the fade-in still starts in the `finally` block below — Dart /// is the only audible source either way, so the ring must never stay /// stuck at [_volumenInicialFadeIn] forever. Future _confirmarAudioFlutterListo() async { if (_audioFlutterConfirmado) return; _audioFlutterConfirmado = true; try { await context.read().android.confirmarAudioFlutter( widget.alarma.id, ); } catch (e) { debugPrint('[PluriWave][alarmas] confirmar audio flutter fallo: $e'); } finally { _iniciarFadeIn(); } } /// Restores the ring-scoped `STREAM_MUSIC` override at most once per /// screen instance (Requirement: Ring-scoped device-volume override, /// Scenario "Restore is idempotent across double-exit paths"). Both /// `_silenciarAudio` (dismiss/snooze) and `dispose` call this; the guard /// here plus the idempotent Kotlin-side restore together keep any /// exit-path ordering safe. Failures never propagate — a broken restore /// must not block dismiss/snooze. Future _restaurarVolumenMediaUnaVez() async { if (_volumenMediaRestaurado) return; _volumenMediaRestaurado = true; try { await _estadoAlarmas.android.restaurarVolumenMedia(); } catch (e) { debugPrint('[PluriWave][alarmas] restaurar volumen media fallo: $e'); } } /// 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(); await _silenciarAudio(radio); // Dismiss is run from finally so a failing reschedule/teardown can never // leave the ringing screen stuck open (which would also block the next // ring via the _alarmaSonandoActiva guard in app.dart). try { await alarmas.finalizarEjecucion(widget.alarma.id); } catch (e) { debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e'); } finally { if (mounted) _dismissScreen(); } } /// 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(); // Captured BEFORE dismiss (Design D4): the ringing screen dismisses by // design below, so the messenger must outlive it for the failure // SnackBar to still be shown. final messenger = ScaffoldMessenger.of(context); await _silenciarAudio(radio); // posponerAlarma no longer throws on a native scheduling failure (it // records the failure into EstadoAlarmas.error and always calls // notifyListeners instead) — the dismiss-in-finally below is now a // structural safety net, not a workaround for an expected throw. The // screen still closes either way (dismiss-by-design); the failure is // reported to the user via a SnackBar, not silently swallowed. try { await alarmas.posponerAlarma(widget.alarma, minutos); final error = alarmas.error; if (error != null) { messenger.showSnackBar(SnackBar(content: Text(error))); } } catch (e) { debugPrint('[PluriWave][alarmas] posponer alarma fallo: $e'); } finally { if (mounted) _dismissScreen(); } } /// Stops both audio sources (local fallback player and the radio handler) /// without letting either failure abort the caller's dismiss flow. Future _silenciarAudio(EstadoRadio radio) async { try { await _liberarAudioLocal(); } catch (e) { debugPrint('[PluriWave][alarmas] liberar audio local fallo: $e'); } try { await radio.audio.pausar(); } catch (e) { debugPrint('[PluriWave][alarmas] pausar radio fallo: $e'); } await _restaurarVolumenMediaUnaVez(); } /// Dismisses the alarm screen safely in both live-app and dead-app states. /// /// When the alarm screen is the root activity (launched via full-screen intent /// from a dead app), [Navigator.canPop] returns false and calling /// [Navigator.pop] would be a no-op. In that case [SystemNavigator.pop] is /// used to call `Activity.finish()` and return to the home screen. void _dismissScreen() { final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.pop(); } else { SystemNavigator.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(); unawaited(_restaurarVolumenMediaUnaVez()); 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')}';