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; @override void initState() { super.initState(); } /// 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(); // 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): 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() { 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), ), ], ), ).pluriFadeIn(context), ), ), ), ); } } String _hora(AlarmaMusical alarma) => '${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';