refactor(alarm): make the ringing screen pure UI over a reduced native port
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:
+1
-24
@@ -89,7 +89,6 @@ class _PaginaPrincipal extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
static const _volumenInicialFadeInAlarmas = 0.05;
|
||||
int _indice = 0;
|
||||
StreamSubscription<String>? _errorSubscription;
|
||||
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
|
||||
@@ -353,15 +352,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
_alarmaSonandoId = alarma.id;
|
||||
|
||||
try {
|
||||
await _prearrancarAudioAlarma(alarma);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder:
|
||||
(_) => PantallaAlarmaSonando(
|
||||
alarma: alarma,
|
||||
audioPrearrancado: alarma.emisora != null,
|
||||
),
|
||||
builder: (_) => PantallaAlarmaSonando(alarma: alarma),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
@@ -373,23 +367,6 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
|
||||
// The ring-scoped stream override is NOT raised here anymore: the ringing
|
||||
// screen owns it and raises it only when audio is actually about to be
|
||||
// audible (first `reproduciendo`, or right before the fallback WAV
|
||||
// plays). Raising it during pre-start let the ExoPlayer track spin-up —
|
||||
// which briefly runs at gain 1.0 — blast at the ring level; under the
|
||||
// user's original stream volume that spin-up is inaudible.
|
||||
final emisora = alarma.emisora;
|
||||
if (emisora == null) return;
|
||||
final radio = context.read<EstadoRadio>();
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] prearrancar emisora alarma id=${alarma.id} emisora=${emisora.nombre}',
|
||||
);
|
||||
await radio.audio.setVolumen(_volumenInicialFadeInAlarmas);
|
||||
unawaited(radio.reproducir(emisora));
|
||||
}
|
||||
|
||||
void _mostrarTimerDialog(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
||||
@@ -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')}';
|
||||
|
||||
@@ -142,19 +142,6 @@ abstract class PuertoAlarmasAndroid {
|
||||
Future<bool> solicitarPermisoNotificaciones();
|
||||
Future<bool> solicitarPermisoPantallaCompleta();
|
||||
Future<bool> solicitarExencionBateria();
|
||||
Future<void> confirmarAudioFlutter(String alarmaId);
|
||||
|
||||
/// Forces `STREAM_MUSIC` to the fixed audible reference level for the
|
||||
/// duration of an alarm ring so the alarm cannot be silenced by a device
|
||||
/// media volume of 0 (Requirement: Ring-scoped device-volume override).
|
||||
/// [fraccion] is reserved for future tuning; the current native
|
||||
/// implementation always targets the device max regardless of its value.
|
||||
Future<void> forzarVolumenMediaParaAlarma(double fraccion);
|
||||
|
||||
/// Restores `STREAM_MUSIC` to the value captured by
|
||||
/// [forzarVolumenMediaParaAlarma]. Idempotent: safe to call even when no
|
||||
/// override is active or it was already restored.
|
||||
Future<void> restaurarVolumenMedia();
|
||||
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico();
|
||||
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
|
||||
@@ -290,18 +277,6 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
Future<void> detenerSonidoNativo(String alarmaId) =>
|
||||
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
|
||||
|
||||
@override
|
||||
Future<void> confirmarAudioFlutter(String alarmaId) =>
|
||||
_logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId});
|
||||
|
||||
@override
|
||||
Future<void> forzarVolumenMediaParaAlarma(double fraccion) =>
|
||||
_logAndInvokeVoid('overrideMediaVolumeForRing', {'fraction': fraccion});
|
||||
|
||||
@override
|
||||
Future<void> restaurarVolumenMedia() =>
|
||||
_logAndInvokeVoid('restoreMediaVolume', {});
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||
final abierto = await _channel.invokeMethod<bool>(
|
||||
|
||||
@@ -17,31 +17,11 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
bool ignoraOptimizacionBateria = true;
|
||||
int solicitudesExencionBateria = 0;
|
||||
|
||||
/// Records each [forzarVolumenMediaParaAlarma] call (Slice 2: ring-scoped
|
||||
/// device-volume override).
|
||||
final volumenForzado = <double>[];
|
||||
|
||||
/// Counts [restaurarVolumenMedia] calls (Slice 2). A plain counter, not a
|
||||
/// list: widget tests only need to assert how many times restore ran.
|
||||
int volumenRestaurado = 0;
|
||||
|
||||
/// Test-only failure switch (Design D7): when true, [programar] throws
|
||||
/// instead of scheduling, enabling failure-path coverage that the fake
|
||||
/// could not otherwise produce.
|
||||
bool fallaProgramar = false;
|
||||
|
||||
/// Test-only gate (Slice 3: fade-in dedup at handoff). When set,
|
||||
/// [confirmarAudioFlutter] suspends on this completer before resolving,
|
||||
/// letting a test observe the pre-confirm state deterministically instead
|
||||
/// of racing real stream/timer scheduling. Complete it to let the call
|
||||
/// proceed.
|
||||
Completer<void>? puertaConfirmarAudioFlutter;
|
||||
|
||||
/// Test-only failure switch (Slice 3 edge case): when true,
|
||||
/// [confirmarAudioFlutter] throws after the gate above (if any) resolves,
|
||||
/// simulating a dead/never-there native channel at handoff.
|
||||
bool fallaConfirmarAudioFlutter = false;
|
||||
|
||||
/// Simulates a native -> Flutter `alarmFired` MethodChannel event.
|
||||
void emitirEvento(EventoAlarmaAndroid evento) => _eventos.add(evento);
|
||||
|
||||
@@ -74,27 +54,6 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
ocultadas.add(alarmaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> confirmarAudioFlutter(String alarmaId) async {
|
||||
if (puertaConfirmarAudioFlutter != null) {
|
||||
await puertaConfirmarAudioFlutter!.future;
|
||||
}
|
||||
if (fallaConfirmarAudioFlutter) {
|
||||
throw StateError('fake confirmarAudioFlutter failure');
|
||||
}
|
||||
detenidas.add(alarmaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> forzarVolumenMediaParaAlarma(double fraccion) async {
|
||||
volumenForzado.add(fraccion);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restaurarVolumenMedia() async {
|
||||
volumenRestaurado++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
|
||||
DiagnosticoAlarmasAndroid(
|
||||
|
||||
@@ -59,10 +59,7 @@ Future<void> _montarComoRaiz(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
home: PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -106,10 +103,8 @@ Future<void> _montarConHistorial(
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder:
|
||||
(_) => PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
(_) =>
|
||||
PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
),
|
||||
@@ -357,90 +352,27 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('PantallaAlarmaSonando media-volume override restore (Slice 2)', () {
|
||||
testWidgets('detener: restaura el volumen de medios exactamente una vez', (
|
||||
tester,
|
||||
) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
group(
|
||||
'EstadoRadio reproduccion normal nunca toca el puerto de alarmas nativo '
|
||||
'(guardia de regresion)',
|
||||
() {
|
||||
test(
|
||||
'ciclo de reproducir/pausar fuera de una alarma no invoca al puerto '
|
||||
'de alarmas nativo',
|
||||
() async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
final emisora = env.estadoAlarmas.alarmas.single.emisora!;
|
||||
final programadasAntes = env.android.programadas.length;
|
||||
|
||||
await _montarConHistorial(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
await env.radio.reproducir(emisora);
|
||||
await env.radio.audio.pausar();
|
||||
|
||||
expect(env.android.programadas.length, programadasAntes);
|
||||
expect(env.android.detenidas, isEmpty);
|
||||
expect(env.android.ocultadas, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(env.android.volumenRestaurado, 1);
|
||||
});
|
||||
|
||||
testWidgets('posponer: restaura el volumen de medios exactamente una vez', (
|
||||
tester,
|
||||
) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
|
||||
await _montarConHistorial(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(env.android.volumenRestaurado, 1);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'detener: _silenciarAudio y dispose en secuencia no duplican la '
|
||||
'restauracion (idempotencia en el call-site Dart)',
|
||||
(tester) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
|
||||
await _montarConHistorial(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Proves dispose() really ran too (both call sites fired) — the
|
||||
// idempotence guard must still cap the counter at 1.
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
expect(
|
||||
env.android.volumenRestaurado,
|
||||
1,
|
||||
reason:
|
||||
'restaurarVolumenMedia debe invocarse a lo sumo una vez por '
|
||||
'pantalla, aunque _silenciarAudio (dentro de _detener) y '
|
||||
'dispose() ambos lo llamen',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('EstadoRadio reproduccion normal nunca dispara el override de volumen '
|
||||
'(Slice 2, guardia de regresion)', () {
|
||||
test('ciclo de reproducir/pausar fuera de una alarma no toca el canal '
|
||||
'de volumen de medios', () async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
final emisora = env.estadoAlarmas.alarmas.single.emisora!;
|
||||
|
||||
await env.radio.reproducir(emisora);
|
||||
await env.radio.audio.pausar();
|
||||
|
||||
expect(env.android.volumenForzado, isEmpty);
|
||||
expect(env.android.volumenRestaurado, 0);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ Future<void> _montarPantalla(
|
||||
builder:
|
||||
(_) => PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
|
||||
@@ -32,12 +32,6 @@ Future<_Entorno> _montarPantalla(
|
||||
WidgetTester tester, {
|
||||
int snoozeMinutos = 5,
|
||||
int fadeInSegundos = 0,
|
||||
// Slice 3 (fade-in dedup): existing callers rely on the radio already
|
||||
// being "reproduciendo" by mount time, which cancels the fallback timer
|
||||
// synchronously and leaves nothing to observe mid-handoff. Fade-in-gate
|
||||
// tests need a live `_estadoSub` subscriber instead, so they set this to
|
||||
// false and emit `reproduciendo` themselves after the widget mounts.
|
||||
bool audioYaReproduciendo = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
@@ -45,9 +39,7 @@ Future<_Entorno> _montarPantalla(
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final audio = FakeServicioAudio();
|
||||
if (audioYaReproduciendo) {
|
||||
audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
}
|
||||
audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
final radio = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
@@ -104,10 +96,8 @@ Future<_Entorno> _montarPantalla(
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder:
|
||||
(_) => PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
(_) =>
|
||||
PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
),
|
||||
@@ -148,7 +138,7 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'posponer 5 min detiene el audio local, pospone y cierra (S2-R1-B)',
|
||||
'posponer 5 min pospone la alarma y cierra la pantalla (S2-R1-B)',
|
||||
(tester) async {
|
||||
final entorno = await _montarPantalla(tester, snoozeMinutos: 5);
|
||||
|
||||
@@ -157,7 +147,6 @@ void main() {
|
||||
|
||||
final alarma = entorno.estadoAlarmas.alarmas.single;
|
||||
expect(alarma.snoozeHasta, DateTime(2026, 6, 11, 7, 35));
|
||||
expect(entorno.audio.pausas, greaterThanOrEqualTo(1));
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
// posponerAlarma oculta la notificacion nativa (mismo stop path que
|
||||
// el boton de detener) y reprograma con el snooze.
|
||||
@@ -169,141 +158,29 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
group('rampa anclada y override diferido al primer audio', () {
|
||||
testWidgets('la rampa arranca al montar sin esperar al stream, y el '
|
||||
'override del volumen del dispositivo espera a "reproduciendo"',
|
||||
(tester) async {
|
||||
final entorno = await _montarPantalla(
|
||||
tester,
|
||||
audioYaReproduciendo: false,
|
||||
);
|
||||
|
||||
// La rampa esta anclada al inicio de la alarma: con fade 0 salta ya al
|
||||
// objetivo (1.0) aunque el stream siga bufferizando. Lo que NO debe
|
||||
// haber ocurrido todavia es el override del stream del dispositivo
|
||||
// (el spin-up del reproductor debe pasar bajo el volumen original) ni
|
||||
// el stop nativo del handoff.
|
||||
expect(entorno.audio.volumenesAplicados, [0.05, 1.0]);
|
||||
expect(
|
||||
entorno.android.volumenForzado,
|
||||
isEmpty,
|
||||
reason: 'el override debe esperar a que el audio realmente fluya',
|
||||
);
|
||||
expect(entorno.android.detenidas, isEmpty);
|
||||
|
||||
// La radio llega a "reproduciendo": recien ahi se sube el stream al
|
||||
// nivel configurado y se confirma el handoff (stop nativo).
|
||||
entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(entorno.android.volumenForzado, [0.85]);
|
||||
expect(entorno.android.detenidas, contains('ring1'));
|
||||
});
|
||||
|
||||
testWidgets('si confirmar el audio con el nativo falla, el fade-in de Dart '
|
||||
'arranca igual (el nativo esta muerto o nunca corrio)', (tester) async {
|
||||
final entorno = await _montarPantalla(
|
||||
tester,
|
||||
audioYaReproduciendo: false,
|
||||
);
|
||||
entorno.android.fallaConfirmarAudioFlutter = true;
|
||||
|
||||
entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// La confirmacion nativa fallo (el fake lanza antes de registrar en
|
||||
// `detenidas`), pero el fade-in de Dart debe arrancar de todas
|
||||
// formas: si el lado nativo esta muerto o nunca corrio, Dart es la
|
||||
// unica fuente audible, y el ring no debe quedar pegado en
|
||||
// _volumenInicialFadeIn para siempre.
|
||||
expect(entorno.android.detenidas, isEmpty);
|
||||
expect(entorno.audio.volumenesAplicados, [0.05, 1.0]);
|
||||
});
|
||||
});
|
||||
|
||||
group('handoff con audio prearrancado ya reproduciendo (regresion)', () {
|
||||
testWidgets('si la radio ya esta reproduciendo al montar, la confirmacion '
|
||||
'del handoff y el fade-in arrancan igual', (tester) async {
|
||||
// Real production path: app.dart pre-starts the station BEFORE the
|
||||
// screen mounts, so `reproduciendo` is emitted before the state
|
||||
// listener subscribes and no further state event ever arrives. The
|
||||
// handoff confirmation (native stop) and the Dart ramp must not
|
||||
// depend on catching that already-missed event.
|
||||
final entorno = await _montarPantalla(tester);
|
||||
|
||||
expect(
|
||||
entorno.android.detenidas,
|
||||
contains('ring1'),
|
||||
reason: 'el nativo debe recibir el stop del handoff aunque '
|
||||
'"reproduciendo" haya llegado antes del mount',
|
||||
);
|
||||
expect(
|
||||
entorno.audio.volumenesAplicados,
|
||||
[0.05, 1.0],
|
||||
reason: 'el fade-in debe arrancar tambien en el camino ya-sonando',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('salidas del ring fuera de los botones', () {
|
||||
testWidgets('el boton atras del sistema se comporta como Detener: para la '
|
||||
'radio, finaliza y cierra', (tester) async {
|
||||
final entorno = await _montarPantalla(tester);
|
||||
testWidgets(
|
||||
'el boton atras del sistema se comporta como Detener: finaliza la '
|
||||
'ejecucion y cierra',
|
||||
(tester) async {
|
||||
final entorno = await _montarPantalla(tester);
|
||||
|
||||
// Simulate the SYSTEM back (routes through PopScope via the binding),
|
||||
// not a direct Navigator.pop: a plain pop only runs dispose(), which
|
||||
// leaves the shared radio player ringing with no alarm UI left to
|
||||
// stop it.
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
// Simulate the SYSTEM back (routes through PopScope via the
|
||||
// binding), not a direct Navigator.pop: a plain pop only runs
|
||||
// dispose(), which would leave no alarm UI anywhere to stop the
|
||||
// ring (native audio teardown is driven by finalizarEjecucion, the
|
||||
// same path Detener uses).
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
expect(
|
||||
entorno.audio.pausas,
|
||||
greaterThanOrEqualTo(1),
|
||||
reason: 'atras debe pausar la radio como lo hace Detener',
|
||||
);
|
||||
expect(
|
||||
entorno.android.ocultadas,
|
||||
contains('ring1'),
|
||||
reason: 'atras debe finalizar la ejecucion (mismo camino que Detener)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('salir a mitad de rampa restaura la ganancia del reproductor '
|
||||
'a 1.0 para la radio normal', (tester) async {
|
||||
final entorno = await _montarPantalla(tester, fadeInSegundos: 30);
|
||||
|
||||
// Let the ramp advance a few steps (250ms per step) so the shared
|
||||
// handler gain sits at a partial value well below 1.0.
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(entorno.audio.volumenesAplicados.last, lessThan(0.2));
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
entorno.audio.volumenesAplicados.last,
|
||||
1.0,
|
||||
reason: 'la salida del ring no debe dejar la radio normal al nivel '
|
||||
'parcial de la rampa',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('restore de volumen de medios con dispose como unico llamador', () {
|
||||
testWidgets('desmontar la pantalla sin detener ni posponer restaura el '
|
||||
'volumen exactamente una vez', (tester) async {
|
||||
final entorno = await _montarPantalla(tester);
|
||||
|
||||
// Teardown that bypasses _detener()/_posponer() entirely: dispose()
|
||||
// must work as a restore path on its own. Reading the BuildContext
|
||||
// inside dispose() throws (the element is already defunct), so the
|
||||
// port reference has to be captured while the widget is mounted.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(entorno.android.volumenRestaurado, 1);
|
||||
});
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
expect(
|
||||
entorno.android.ocultadas,
|
||||
contains('ring1'),
|
||||
reason:
|
||||
'atras debe finalizar la ejecucion (mismo camino que Detener)',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,31 +107,4 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'forzarVolumenMediaParaAlarma invoca overrideMediaVolumeForRing con fraction',
|
||||
() async {
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
|
||||
await servicio.forzarVolumenMediaParaAlarma(1.0);
|
||||
|
||||
final llamada = llamadas.singleWhere(
|
||||
(c) => c.method == 'overrideMediaVolumeForRing',
|
||||
);
|
||||
expect(llamada.arguments, {'fraction': 1.0});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'restaurarVolumenMedia invoca restoreMediaVolume sin argumentos',
|
||||
() async {
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
|
||||
await servicio.restaurarVolumenMedia();
|
||||
|
||||
final llamada = llamadas.singleWhere(
|
||||
(c) => c.method == 'restoreMediaVolume',
|
||||
);
|
||||
expect(llamada.arguments, <String, Object?>{});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user