refactor(alarm): make the ringing screen pure UI over a reduced native port
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m40s

PantallaAlarmaSonando no longer owns any audio orchestration (fallback
player, dB ramp, native handoff confirm, media-volume override/restore):
it only calls EstadoAlarmas.finalizarEjecucion/posponerAlarma from
Stop/Snooze/back, keeping the single-exit guard, PopScope back=Stop and
dismiss semantics intact. The status line now reads directly from the
alarm's static config (station name or a neutral label) instead of a
live playback/handoff state.

PuertoAlarmasAndroid drops confirmarAudioFlutter,
forzarVolumenMediaParaAlarma and restaurarVolumenMedia, and app.dart no
longer pre-starts a station before pushing the ring screen. This is the
Dart half of moving to a single native ring-audio owner (WU1 of 2); the
Kotlin service rebuild lands next and keeps this intermediate state
shippable with no double audio.
This commit is contained in:
2026-07-12 11:36:31 +02:00
parent 836fb44adb
commit bd7f883118
8 changed files with 73 additions and 635 deletions
+23 -277
View File
@@ -2,15 +2,12 @@ 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';
@@ -18,247 +15,34 @@ 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,
});
const PantallaAlarmaSonando({super.key, required this.alarma});
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;
@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<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).
@@ -271,19 +55,17 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
}
}
/// 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.
/// 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<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
@@ -303,34 +85,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
}
}
/// 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
@@ -355,11 +109,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
@override
void dispose() {
_fallbackTimer?.cancel();
_fadeInTimer?.cancel();
_estadoSub?.cancel();
_fallbackPlayer.dispose();
unawaited(_restaurarVolumenMediaUnaVez());
super.dispose();
}
@@ -374,8 +123,9 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
// 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.
// 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;
@@ -424,12 +174,14 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
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(
_fallbackActivo
? l10n.alarmRingingFallbackActive
: _radioIntentada
? l10n.alarmRingingTryingStation
: l10n.alarmRingingPreparingFallback,
alarma.emisora != null
? localizedStationName(l10n, alarma.emisora!.nombre)
: l10n.alarmRingingNotificationTitle,
textAlign: TextAlign.center,
),
const SizedBox(height: 22),
@@ -467,11 +219,5 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
}
}
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')}';