posponerAlarma() and posponerProximaDesdePreaviso() called the native scheduler with no error handling, unlike guardarAlarma(). When the native call failed (e.g. revoked exact-alarm permission), the exception escaped before notifyListeners() ran, leaving the alarm list stuck on stale data with no real alarm scheduled and no snooze countdown notification. Both methods now mirror guardarAlarma()'s pattern: permission pre-check, try/catch into _error, and an unconditional notifyListeners() so the UI always reflects the outcome. Failures surface via SnackBar in the ringing screen and in app.dart's postpone-next handler.
339 lines
12 KiB
Dart
339 lines
12 KiB
Dart
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<PantallaAlarmaSonando> createState() => _PantallaAlarmaSonandoState();
|
|
}
|
|
|
|
class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|
static const _volumenInicialFadeIn = 0.05;
|
|
static const _fadeStep = Duration(milliseconds: 250);
|
|
final AudioPlayer _fallbackPlayer = AudioPlayer();
|
|
StreamSubscription<EstadoReproduccion>? _estadoSub;
|
|
Timer? _fallbackTimer;
|
|
Timer? _fadeInTimer;
|
|
bool _fallbackActivo = false;
|
|
bool _radioIntentada = false;
|
|
bool _audioFlutterConfirmado = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma());
|
|
}
|
|
|
|
Future<void> _iniciarAlarma() async {
|
|
final radio = context.read<EstadoRadio>();
|
|
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<void> _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<void> _aplicarVolumenGlobal(double volumen) async {
|
|
if (!mounted) return;
|
|
final radio = context.read<EstadoRadio>();
|
|
await radio.audio.setVolumen(volumen.clamp(0.0, 1.0));
|
|
await _fallbackPlayer.setVolume(volumen.clamp(0.0, 1.0));
|
|
}
|
|
|
|
Future<void> _confirmarAudioFlutterListo() async {
|
|
if (_audioFlutterConfirmado) return;
|
|
_audioFlutterConfirmado = true;
|
|
await context.read<EstadoAlarmas>().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<void> _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<void> _detener() async {
|
|
final radio = context.read<EstadoRadio>();
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
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<void> _posponer(int minutos) async {
|
|
final radio = context.read<EstadoRadio>();
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
// 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<void> _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');
|
|
}
|
|
}
|
|
|
|
/// 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<int> _opcionesSnooze() {
|
|
final opciones = <int>{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')}';
|