Files
pluriwave/lib/pantallas/pantalla_alarma_sonando.dart
T
FreeTLab 2e64740b26
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s
fix(alarm): anchor the fade at alarm time and defer the override to first audio
On-device logcat from the latest test showed two defects the previous
design created. The fade-in was gated on the station reaching
`reproduciendo`, and the stream took 18.7 seconds to buffer: the ring
sat frozen at 5% the whole time and the configured fade seconds only
started counting afterwards. And the stream override was raised during
pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0
for an instant before the player gain lands — blasted at the configured
ring level, heard as "starts directly at the alarm volume".

The ramp is now anchored at alarm time: it starts when the screen
starts, buffering just joins it at the elapsed level, and the fade
duration means seconds-from-alarm. _iniciarFadeIn is single-start so
the handoff confirmation and fallback paths can no longer restart an
in-progress ramp from 5%. The stream override moved from the app-side
pre-start into the screen and is raised only when audio is actually
about to flow (first `reproduciendo`, the already-playing branch, or
right before the fallback WAV plays), so track spin-up happens under
the user's original low volume and the blast is physically impossible.
Exit teardown restores the device stream before resetting the player
gain, removing the brief exit blip seen in the capture.
2026-07-12 00:35:29 +02:00

478 lines
19 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 '../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,
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 _volumenMediaForzado = false;
bool _volumenMediaRestaurado = false;
bool _fadeInArrancado = 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));
}
// The ramp is anchored at ALARM time, not at stream-connect time: the
// configured fade seconds count from the moment the alarm starts, so a
// slow station buffering for many seconds cannot freeze the ring at 5%
// (observed on-device: 18.7s stuck waiting for `reproduciendo`). While
// the stream is still connecting nothing is audible anyway; when audio
// starts it simply joins the ramp at the elapsed level.
_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();
// Raise the ring-scoped stream override only now that audio is
// actually flowing: the ExoPlayer AudioTrack spins up at gain 1.0
// for an instant before the player gain lands, and doing that under
// the user's ORIGINAL (low) stream volume makes the spin-up blast
// physically impossible. Order matters: override first, then the
// native handoff stop.
unawaited(_forzarVolumenMediaUnaVez());
_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 stream override must not depend on catching that
// already-missed event, so this branch handles them explicitly too
// (idempotent — the listener firing as well is harmless).
if (widget.audioPrearrancado && radio.audio.estaSonando) {
_fallbackTimer?.cancel();
await _forzarVolumenMediaUnaVez();
await _confirmarAudioFlutterListo();
}
}
Future<void> _iniciarFallback() async {
if (_fallbackActivo) return;
_fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
// The local WAV is about to be audible: raise the stream override before
// play so the fallback honors the configured ring level too.
await _forzarVolumenMediaUnaVez();
await _fallbackPlayer.play();
await _confirmarAudioFlutterListo();
if (mounted) setState(() {});
}
/// Raises the ring-scoped `STREAM_MUSIC` override (device-volume
/// independence: the ring is audible even with the device at 0, capped at
/// the alarm's configured fraction of the device maximum) at most once per
/// screen instance, and only when audio is about to be audible — never
/// during player spin-up, so track creation can't blast at full gain.
Future<void> _forzarVolumenMediaUnaVez() async {
if (_volumenMediaForzado) return;
_volumenMediaForzado = true;
try {
await _estadoAlarmas.android.forzarVolumenMediaParaAlarma(
widget.alarma.volumen.clamp(0.0, 1.0),
);
} catch (e) {
debugPrint('[PluriWave][alarmas] forzar volumen media fallo: $e');
}
}
void _iniciarFadeIn() {
// Anchored, single-start ramp: the first caller (screen start) wins and
// later idempotent calls (handoff confirm, fallback) must NOT restart it
// from 5% — that would audibly drop an already-progressed ring.
if (_fadeInArrancado) return;
_fadeInArrancado = true;
_fadeInTimer?.cancel();
// The media stream is capped at the alarm's configured volume
// (forzarVolumenMediaParaAlarma), so the player ramps up to its OWN full
// range under that cap. Perceived peak = configured% of the device max,
// reached gradually from ~5% of the cap; ramping the player to
// widget.alarma.volumen here would double-attenuate (configured x
// configured) and undershoot the level the user chose.
const volumenObjetivo = 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<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. The Dart ramp is anchored at screen start and does NOT wait
/// for this confirmation (a slow stream would freeze the ring at 5%); the
/// `finally` below only guarantees the ramp exists on exotic paths where
/// `_iniciarAlarma` never reached it — `_iniciarFadeIn` is single-start,
/// so an already-running ramp is never restarted. Both ramps (native on
/// the ALARM stream, Dart under the capped media stream) start at 5% on
/// the same fade duration, so they stay aligned until the native track is
/// stopped here.
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();
}
/// 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;
Future<void> _detener() async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
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 {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
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');
}
// Restore the DEVICE stream first, then the player gain: raising the
// gain to 1.0 while the stream is still at the ring level would be
// audible for an instant if the pause hasn't fully landed.
await _restaurarVolumenMediaUnaVez();
try {
// The fade-in mutates the SHARED radio handler gain; exiting mid-ramp
// would otherwise leave every later radio play at the partial ramp
// level until the next full ramp or app restart. 1.0 is the handler's
// default gain; the player is already paused, so this is inaudible.
await radio.audio.setVolumen(1.0);
} catch (e) {
debugPrint('[PluriWave][alarmas] restaurar ganancia 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();
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 PopScope(
// System back / predictive back must behave exactly like Stop: a plain
// route pop would run only dispose(), leaving the shared radio player
// ringing with no alarm UI left anywhere to stop it.
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),
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')}';