import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../estado/estado_alarmas.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../tema/pluri_animate.dart'; import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_tokens.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_wave_scaffold.dart'; class PantallaAlarmaSonando extends StatefulWidget { const PantallaAlarmaSonando({super.key, required this.alarma}); final AlarmaMusical alarma; @override State createState() => _PantallaAlarmaSonandoState(); } class _PantallaAlarmaSonandoState extends State { /// Single-exit guard: Stop, snooze and the system back gesture all funnel /// into the same teardown; whichever lands first wins and the rest no-op, /// so a back-press racing a button tap can never run the exit flow twice /// (a second _dismissScreen would pop the route UNDER the alarm screen). bool _salidaEnCurso = false; /// Retryable force-stop affordance (Finding A, spec `alarm-stop-safety` / /// "Retryable Force-Stop Affordance"): true while a VERIFIED stop failure /// (or an unknown-state exception) is outstanding. Unlike a timed SnackBar, /// this drives a persistent in-screen banner that stays until a confirmed /// stop clears it — the ring is still audible while this is true, so the /// screen intentionally does NOT dismiss. bool _falloDetencionVisible = false; late final EstadoAlarmas _alarmas; @override void initState() { super.initState(); _alarmas = context.read(); _alarmas.addListener(_alReconciliarFinExterno); } /// External end-of-ring reconciliation (RES-1): if this alarm's occurrence /// gets recorded as MISSED while this screen is up, auto-dismiss instead of /// leaving a stale ringing screen with no audio behind it. void _alReconciliarFinExterno() { if (_salidaEnCurso || !mounted) return; if (_alarmas.ultimaAlarmaPerdidaId != widget.alarma.id) return; _salidaEnCurso = true; _dismissScreen(); } /// Pure UI: the ring's audio is owned entirely by the native /// PluriWaveAlarmService. This screen only reports the outcome to /// EstadoAlarmas; it never touches an audio player or a device-volume /// channel. Future _detener() async { if (_salidaEnCurso) return; _salidaEnCurso = true; final alarmas = context.read(); final alarmaId = widget.alarma.id; try { await alarmas.finalizarEjecucion(alarmaId); if (alarmas.error != null) { // Verified stop failure (Finding A): the alarm is still ringing, so // dismissing now would hide the only retry affordance. Reset the // single-exit guard so a retry (this button again, back gesture, or // the banner's own action below) can run the teardown again. _salidaEnCurso = false; if (mounted) setState(() => _falloDetencionVisible = true); return; } } catch (e) { debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e'); // Unknown state (Finding A): treat exactly like a verified failure — // stay and show the retry banner. The notification's native Stop // action remains the out-of-band fallback, and PopScope already routes // back through this same method on a subsequent back-press. _salidaEnCurso = false; if (mounted) setState(() => _falloDetencionVisible = true); return; } if (mounted) _dismissScreen(); } /// Retry action bound to the persistent force-stop banner (Finding A, /// SS-3b): re-invokes the fail-safe stop directly; dismisses ONLY on a /// confirmed success, otherwise the banner stays exactly as it was. Future _forzarDetencion() async { if (_salidaEnCurso) return; _salidaEnCurso = true; final alarmas = context.read(); await alarmas.forzarDetencion(widget.alarma.id); if (alarmas.error == null) { if (mounted) _dismissScreen(); } else { // Verified failure (RES-2): reset the guard so the banner's own retry // action (or another button) can run the teardown again. _salidaEnCurso = false; if (mounted) setState(() {}); } } /// Flutter-first snooze (S2-R1): 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 { if (_salidaEnCurso) return; _salidaEnCurso = true; 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); // 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(); } } /// 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() { _alarmas.removeListener(_alReconciliarFinExterno); 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 PopScope( // System back / predictive back must behave exactly like Stop: a plain // route pop would run only dispose(), leaving the native ring audible // with no alarm UI left to stop it (the native service is the sole // audio owner and is torn down via the same finalizarEjecucion path). canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; unawaited(_detener()); }, child: _cuerpo(context, alarma, l10n, tokens), ); } Widget _cuerpo( BuildContext context, AlarmaMusical alarma, AppLocalizations l10n, PluriWaveTokens tokens, ) { 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), // Static status line (Design D8): sourced only from // widget.alarma, never from a live audio/player state — the // ring's own audio state is owned natively and this screen // has no channel back to it. Text( alarma.emisora != null ? localizedStationName(l10n, alarma.emisora!.nombre) : l10n.alarmRingingNotificationTitle, 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), ), if (_falloDetencionVisible) ...[ const SizedBox(height: 14), _bannerFalloDetencion(context, l10n, tokens), ], ], ), ).pluriFadeIn(context), ), ), ), ); } /// Persistent force-stop retry banner (Finding A, spec `alarm-stop-safety` /// / "Retryable Force-Stop Affordance"): an in-screen section rather than a /// timed SnackBar, so it stays visible until [_forzarDetencion] confirms a /// stop (or the screen is torn down externally) instead of auto-dismissing /// after a fixed duration. Widget _bannerFalloDetencion( BuildContext context, AppLocalizations l10n, PluriWaveTokens tokens, ) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: tokens.warmCoral.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(16), border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.4)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(l10n.alarmStopFailedMessage, textAlign: TextAlign.center), const SizedBox(height: 8), FilledButton.tonal( onPressed: _forzarDetencion, child: Text(l10n.alarmForceStopAction), ), ], ), ); } } String _hora(AlarmaMusical alarma) => '${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';