From bd7f8831183ec04d99caaae4fc042edbfce5a7c2 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 12 Jul 2026 11:33:37 +0200 Subject: [PATCH] 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. --- lib/app.dart | 25 +- lib/pantallas/pantalla_alarma_sonando.dart | 300 ++---------------- lib/servicios/servicio_alarmas_android.dart | 25 -- test/helpers/fakes_alarmas.dart | 41 --- ...lla_alarma_sonando_dismiss_guard_test.dart | 116 ++----- ...pantalla_alarma_sonando_scaffold_test.dart | 1 - .../pantalla_alarma_sonando_test.dart | 173 ++-------- .../servicio_alarmas_android_test.dart | 27 -- 8 files changed, 73 insertions(+), 635 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 7d01d9a..28671b8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -89,7 +89,6 @@ class _PaginaPrincipal extends StatefulWidget { } class _PaginaPrincipalState extends State<_PaginaPrincipal> { - static const _volumenInicialFadeInAlarmas = 0.05; int _indice = 0; StreamSubscription? _errorSubscription; StreamSubscription? _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( - builder: - (_) => PantallaAlarmaSonando( - alarma: alarma, - audioPrearrancado: alarma.emisora != null, - ), + builder: (_) => PantallaAlarmaSonando(alarma: alarma), fullscreenDialog: true, ), ); @@ -373,23 +367,6 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } } - Future _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(); - 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, diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index a971763..e771843 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -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 createState() => _PantallaAlarmaSonandoState(); } class _PantallaAlarmaSonandoState extends State { - static const _volumenInicialFadeIn = 0.05; - static const _fadeStep = Duration(milliseconds: 250); - final AudioPlayer _fallbackPlayer = AudioPlayer(); - StreamSubscription? _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(); - WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma()); - } - - Future _iniciarAlarma() async { - final radio = context.read(); - 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 _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 _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 _aplicarVolumenGlobal(double volumen) async { - if (!mounted) return; - final radio = context.read(); - 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 _confirmarAudioFlutterListo() async { - if (_audioFlutterConfirmado) return; - _audioFlutterConfirmado = true; - try { - await context.read().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 _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 _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 _detener() async { if (_salidaEnCurso) return; _salidaEnCurso = true; - final radio = context.read(); final alarmas = context.read(); - 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 { } } - /// 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 _posponer(int minutos) async { if (_salidaEnCurso) return; _salidaEnCurso = true; - final radio = context.read(); final alarmas = context.read(); // 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 { } } - /// Stops both audio sources (local fallback player and the radio handler) - /// without letting either failure abort the caller's dismiss flow. - Future _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 { @override void dispose() { - _fallbackTimer?.cancel(); - _fadeInTimer?.cancel(); - _estadoSub?.cancel(); - _fallbackPlayer.dispose(); - unawaited(_restaurarVolumenMediaUnaVez()); super.dispose(); } @@ -374,8 +123,9 @@ class _PantallaAlarmaSonandoState extends State { // 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 { 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 { } } -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')}'; diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 1ec4cdf..f5382fe 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -142,19 +142,6 @@ abstract class PuertoAlarmasAndroid { Future solicitarPermisoNotificaciones(); Future solicitarPermisoPantallaCompleta(); Future solicitarExencionBateria(); - Future 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 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 restaurarVolumenMedia(); Future diagnostico(); Future obtenerEventoInicial(); @@ -290,18 +277,6 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { Future detenerSonidoNativo(String alarmaId) => _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); - @override - Future confirmarAudioFlutter(String alarmaId) => - _logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId}); - - @override - Future forzarVolumenMediaParaAlarma(double fraccion) => - _logAndInvokeVoid('overrideMediaVolumeForRing', {'fraction': fraccion}); - - @override - Future restaurarVolumenMedia() => - _logAndInvokeVoid('restoreMediaVolume', {}); - @override Future solicitarPermisoAlarmasExactas() async { final abierto = await _channel.invokeMethod( diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 7163a90..e64367b 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -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 = []; - - /// 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? 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 confirmarAudioFlutter(String alarmaId) async { - if (puertaConfirmarAudioFlutter != null) { - await puertaConfirmarAudioFlutter!.future; - } - if (fallaConfirmarAudioFlutter) { - throw StateError('fake confirmarAudioFlutter failure'); - } - detenidas.add(alarmaId); - } - - @override - Future forzarVolumenMediaParaAlarma(double fraccion) async { - volumenForzado.add(fraccion); - } - - @override - Future restaurarVolumenMedia() async { - volumenRestaurado++; - } - @override Future diagnostico() async => DiagnosticoAlarmasAndroid( diff --git a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart index 3110f4b..f4872f5 100644 --- a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart @@ -59,10 +59,7 @@ Future _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 _montarConHistorial( navigator.push( MaterialPageRoute( 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); - }); - }); + }, + ); } diff --git a/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart index 452d3bf..732c953 100644 --- a/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart @@ -91,7 +91,6 @@ Future _montarPantalla( builder: (_) => PantallaAlarmaSonando( alarma: estadoAlarmas.alarmas.single, - audioPrearrancado: true, ), fullscreenDialog: true, ), diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart index 5d1f985..b93c811 100644 --- a/test/pantallas/pantalla_alarma_sonando_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -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( 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)', + ); + }, + ); }); } diff --git a/test/servicios/servicio_alarmas_android_test.dart b/test/servicios/servicio_alarmas_android_test.dart index e0f7840..9ccff30 100644 --- a/test/servicios/servicio_alarmas_android_test.dart +++ b/test/servicios/servicio_alarmas_android_test.dart @@ -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, {}); - }, - ); }