_restaurarVolumenMediaUnaVez() read the BuildContext to reach the alarm port, but dispose() runs after the element is defunct, so the lookup always threw (caught and logged) and the dispose safety-net never actually restored the media volume when it was the sole exit path. The state is now captured once in initState and the restore helper uses the field. Adds the missing dispose-as-sole-caller regression test.
378 lines
14 KiB
Dart
378 lines
14 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;
|
|
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<EstadoAlarmas>();
|
|
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));
|
|
}
|
|
|
|
// 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();
|
|
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));
|
|
}
|
|
|
|
/// 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<void> _confirmarAudioFlutterListo() async {
|
|
if (_audioFlutterConfirmado) return;
|
|
_audioFlutterConfirmado = true;
|
|
try {
|
|
await context.read<EstadoAlarmas>().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<void> _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<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');
|
|
}
|
|
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<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();
|
|
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')}';
|