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
+1 -24
View File
@@ -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,
+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')}';
@@ -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>(
-41
View File
@@ -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,
),
+25 -148
View File
@@ -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?>{});
},
);
}