fix(alarm): defer the Dart fade-in until the native handoff confirms
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m50s

The native service and the Flutter player each ran their own 5%-to-
target fade-in, and both could drive audible volume at the handoff,
producing a jump or ramp reset. The Dart ramp now starts exactly once
from the handoff-confirmation path: the player still pre-starts at 5%,
and _confirmarAudioFlutterListo() starts the ramp in a finally block
so it runs whether the native confirmation succeeds or fails — the
alarm can never stay stuck at 5% if the native side is already gone.

Work unit 3/3 of alarm-volume-ramp-restore (fade-in dedup).
This commit is contained in:
2026-07-11 09:57:06 +02:00
parent a6e1177752
commit 66a19525bd
4 changed files with 193 additions and 14 deletions
+19 -5
View File
@@ -64,7 +64,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
if (!widget.audioPrearrancado) {
unawaited(radio.reproducir(emisora));
}
_iniciarFadeIn();
// S7-R4 boundary: only `reproduciendo` cancels the fallback timer —
// `reconectando`/`cargando` do NOT count as playing, so the 12-second
@@ -94,7 +93,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
await _fallbackPlayer.play();
_iniciarFadeIn();
await _confirmarAudioFlutterListo();
if (mounted) setState(() {});
}
@@ -131,12 +129,28 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
await _fallbackPlayer.setVolume(volumen.clamp(0.0, 1.0));
}
/// Confirms the native-to-Flutter audio handoff at most once per screen
/// instance, then starts the single audible Dart fade-in ramp (Slice 3:
/// fade-in dedup at handoff). The native ramp
/// (`PluriWaveAlarmService.startFadeIn`) owns audio until this
/// confirmation lands; starting the Dart ramp any earlier would
/// interleave both ramps and produce an audible jump/reset. If the
/// native confirmation channel call fails (dead or never-there native
/// side), the fade-in still starts in the `finally` block below — Dart
/// is the only audible source either way, so the ring must never stay
/// stuck at [_volumenInicialFadeIn] forever.
Future<void> _confirmarAudioFlutterListo() async {
if (_audioFlutterConfirmado) return;
_audioFlutterConfirmado = true;
await context.read<EstadoAlarmas>().android.confirmarAudioFlutter(
widget.alarma.id,
);
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
@@ -142,11 +142,46 @@ Chain strategy: pending
## Phase 4: Fade-In Dedup at Handoff (Slice 3, strict TDD)
- [ ] 4.1 (RED) In `test/pantallas/pantalla_alarma_sonando_test.dart` (or a new focused fade-in test file), add a widget test asserting the Dart fade-in ramp (observable via `FakeServicioAudio`/fallback player volume changes) does NOT start before `confirmarAudioFlutter` has been invoked on the android port (i.e., before `_confirmarAudioFlutterListo()` runs) — assert `env.android.detenidas` (which `confirmarAudioFlutter` appends to, per `FakePuertoAlarmasAndroid.confirmarAudioFlutter`) is non-empty before any volume-ramp step is observed. Run `flutter test` — confirm it fails (current code starts the ramp at L66 immediately after `radio.reproducir`, before confirmation).
- [ ] 4.2 (RED) Add a test asserting the fade-in DOES start once `_confirmarAudioFlutterListo()` has run (radio path via `estadoStream` emitting `reproduciendo`, or fallback path via `_iniciarFallback`) — the ramp must still function end-to-end after the gate. Run `flutter test` — confirm it fails or is trivially satisfied depending on 4.1's fixture; treat 4.1+4.2 as one RED pair validating gate correctness both ways.
- [ ] 4.3 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, remove the `_iniciarFadeIn()` call at L66 (radio path, currently fires immediately after `radio.reproducir(emisora)`) and the one at L96 (fallback path, currently fires before `_confirmarAudioFlutterListo()` at L97); move the single `_iniciarFadeIn()` invocation INTO `_confirmarAudioFlutterListo()` (L133-139) so it fires exactly once, after the `_audioFlutterConfirmado` guard, for both the radio and fallback paths. Run `flutter test` — confirm 4.1-4.2 pass.
- [ ] 4.4 (REFACTOR) Re-run the full `pantalla_alarma_sonando_test.dart` and `pantalla_alarma_sonando_dismiss_guard_test.dart` suites — confirm no existing fade/dismiss/snooze assertions regressed from moving the ramp start point.
- [ ] 4.5 `flutter analyze` and `dart format .` — confirm clean formatting/lint state for all Slice 3 edits.
- [x] 4.1 (RED) In `test/pantallas/pantalla_alarma_sonando_test.dart` (or a new focused fade-in test file), add a widget test asserting the Dart fade-in ramp (observable via `FakeServicioAudio`/fallback player volume changes) does NOT start before `confirmarAudioFlutter` has been invoked on the android port (i.e., before `_confirmarAudioFlutterListo()` runs) — assert `env.android.detenidas` (which `confirmarAudioFlutter` appends to, per `FakePuertoAlarmasAndroid.confirmarAudioFlutter`) is non-empty before any volume-ramp step is observed. Run `flutter test` — confirm it fails (current code starts the ramp at L66 immediately after `radio.reproducir`, before confirmation).
- [x] 4.2 (RED) Add a test asserting the fade-in DOES start once `_confirmarAudioFlutterListo()` has run (radio path via `estadoStream` emitting `reproduciendo`, or fallback path via `_iniciarFallback`) — the ramp must still function end-to-end after the gate. Run `flutter test` — confirm it fails or is trivially satisfied depending on 4.1's fixture; treat 4.1+4.2 as one RED pair validating gate correctness both ways.
> Implementation note: 4.1+4.2 were implemented as a single RED pair (one `testWidgets`, two
> assertion stages) in `test/pantallas/pantalla_alarma_sonando_test.dart`, radio path. Rather than
> relying on real `estadoStream` subscription timing (existing `_montarPantalla` pre-emits
> `reproduciendo` BEFORE mount in all prior tests, which — traced via code inspection — means
> `_confirmarAudioFlutterListo()` never actually fires in those fixtures: the broadcast stream drops
> the pre-mount event, and the fallback timer's immediate-cancel branch also short-circuits before
> any listener would see it), added a new opt-out param `audioYaReproduciendo` (default `true`,
> preserves all existing tests byte-for-byte) plus a test-only `Completer`-based gate
> (`FakePuertoAlarmasAndroid.puertaConfirmarAudioFlutter`) on `confirmarAudioFlutter`, so the test
> can deterministically observe the pre-confirm state, release the gate, then observe the
> post-confirm state — independent of stream/timer race conditions. Confirmed RED against the
> pre-4.3 code: the gate-pending assertion failed with `Actual: [0.05, 0.85]` (ramp already fired
> before the gate was ever released), proving the ramp was ungated.
>
> Also added a THIRD test beyond the literal 4.1/4.2 wording, covering the explicit edge case in
> this batch's own dispatch instructions: if `confirmarAudioFlutter` FAILS (dead/never-there native
> channel), the Dart ramp must still start — the ring must never stay stuck at
> `_volumenInicialFadeIn` forever, since Dart is the only audible source once native is gone. Added
> `FakePuertoAlarmasAndroid.fallaConfirmarAudioFlutter` to simulate this. Confirmed RED against
> pre-4.3 code too: the fake's `StateError` propagated UNCAUGHT out of `_confirmarAudioFlutterListo()`
> (no try/catch existed), crashing the test — proving the failure path was unhandled before this
> slice.
- [x] 4.3 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, remove the `_iniciarFadeIn()` call at L66 (radio path, currently fires immediately after `radio.reproducir(emisora)`) and the one at L96 (fallback path, currently fires before `_confirmarAudioFlutterListo()` at L97); move the single `_iniciarFadeIn()` invocation INTO `_confirmarAudioFlutterListo()` (L133-139) so it fires exactly once, after the `_audioFlutterConfirmado` guard, for both the radio and fallback paths. Run `flutter test` — confirm 4.1-4.2 pass.
> Implementation note: moved as specified. `_confirmarAudioFlutterListo()` now wraps the native
> channel call in `try { await ... } catch (e) { debugPrint(...); } finally { _iniciarFadeIn(); }` —
> matching the existing `try/catch/finally` idiom already used in `_detener()`/`_posponer()` in the
> same file. The `finally` block is what satisfies the failure-edge case above: whether the native
> confirmation succeeds or throws, the fade-in always starts exactly once (still gated by the
> pre-existing `_audioFlutterConfirmado` guard at the top of the method, unchanged). All 3 new tests
> (4.1+4.2 combined, plus the failure-edge test) pass after this change; `rg '_iniciarFadeIn'
> lib/` confirms exactly one call site remains (inside `_confirmarAudioFlutterListo`) plus the
> function definition itself.
- [x] 4.4 (REFACTOR) Re-run the full `pantalla_alarma_sonando_test.dart` and `pantalla_alarma_sonando_dismiss_guard_test.dart` suites — confirm no existing fade/dismiss/snooze assertions regressed from moving the ramp start point. (Also included `pantalla_alarma_sonando_scaffold_test.dart` and `servicio_alarmas_android_test.dart` for extra confidence since a shared fake was extended. 22/22 tests green, no regressions — none of the pre-existing tests assert on fade/volume timing, since none of them previously exercised `_confirmarAudioFlutterListo()` at all under the old pre-mount-emit fixture pattern, as traced in the 4.1/4.2 note above.)
- [x] 4.5 `flutter analyze` and `dart format .` — confirm clean formatting/lint state for all Slice 3 edits. (`flutter analyze`: 0 issues. `dart format .` reformatted the 3 Slice 3 files cleanly; it also touched 8 files unrelated to this change — pre-existing formatter-version drift on `main`, confirmed reproducible and out of scope per this batch's file-scope constraint — reverted via `git checkout --` both times it recurred, see Phase 6 6.3 note.)
## Phase 5: Manual/On-Device QA (mandatory human gate — Android 14+ physical or emulator device)
@@ -161,6 +196,48 @@ Chain strategy: pending
## Phase 6: Final Static Sweep
- [ ] 6.1 Full-repo `rg 'systemExempted|SYSTEM_EXEMPTED'` across `android/app/src/main` — confirm zero remaining references (fully dropped, not kept alongside `alarm`).
- [ ] 6.2 `flutter test` (full suite) and `flutter analyze` — final clean run before requesting review.
- [ ] 6.3 `dart format .` — confirm no formatting diffs remain uncommitted.
- [x] 6.1 Full-repo `rg 'systemExempted|SYSTEM_EXEMPTED'` across `android/app/src/main` — confirm zero remaining references (fully dropped, not kept alongside `alarm`).
> **INTERPRETATION FLIP (sdd-apply, 2026-07-11):** this check's original "zero remaining
> references" wording was written under the Phase 1 premise that later proved false. Ran it anyway:
> 3 references found (`AndroidManifest.xml:6` permission, `AndroidManifest.xml:58`
> `foregroundServiceType`, `PluriWaveAlarmService.kt:124` runtime constant) — all `systemExempted`/
> `SYSTEM_EXEMPTED`, none `alarm`. This is the CORRECT and EXPECTED state, not a failure: per the
> Phase 1 cancellation banner (design.md correction, confirmed via `javap -constants` SDK
> inspection), `FOREGROUND_SERVICE_TYPE_ALARM`/`FOREGROUND_SERVICE_ALARM` are fictional and
> `mediaPlayback|systemExempted` is confirmed the correct, intentional declaration — so
> "fully dropped" is no longer the right target; "present and internally consistent between
> manifest and runtime" is. Manifest (`systemExempted` at L6+L58) and Kotlin runtime
> (`FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` at L124) match each other exactly, same as before this
> entire change — Slice 1 shipped no code (cancelled), so this file pair was never touched by any
> batch of this change. Marking complete because the check RAN and its result was interpreted
> correctly against the corrected design, not because the literal original wording's outcome was
> achieved.
- [x] 6.2 `flutter test` (full suite) and `flutter analyze` — final clean run before requesting review.
> Implementation note: the full-suite invocation reproduced the documented intermittent hang (this
> task's own dispatch instructions named `estado_alarmas_ejecuciones_test.dart` and
> `servicio_grabacion_radio_test.dart` as known culprits) — it stalled past `+276` tests for several
> minutes of real time with zero further progress and had to be killed. Fell back exactly as
> instructed: ran all 44 other test files (everything under `test/` except those two) together —
> `01:02 +272: All tests passed!`, zero failures. Then ran the two known-flaky files standalone —
> `+7: All tests passed!`, zero failures, confirming (again) they only misbehave under full-suite
> load, not on their own — same conclusion already recorded in Batch 2's apply-progress for
> `servicio_grabacion_radio_test.dart` (`_fallar must clear activa flag immediately`, timing-
> sensitive). 272 + 7 = 279 tests total across all 46 files in the suite, all green. `flutter
> analyze`: 0 issues.
- [x] 6.3 `dart format .` — confirm no formatting diffs remain uncommitted.
> Implementation note: `dart format .` left the 3 files this batch touched
> (`lib/pantallas/pantalla_alarma_sonando.dart`, `test/helpers/fakes_alarmas.dart`,
> `test/pantallas/pantalla_alarma_sonando_test.dart`) untouched on its second run (already clean
> from the 4.5 pass) — confirmed via `git status --short` showing only those 3 files modified. Both
> times `dart format .` ran in this batch (4.5 and 6.3) it ALSO reformatted 8 files this batch never
> edited (`lib/pantallas/pantalla_ajustes.dart`, `lib/servicios/servicio_ecualizador.dart`, and 6
> test files under `test/estado`, `test/pantallas`, `test/servicios`) — reverted both times via `git
> checkout --` to stay within this batch's file-scope constraint (only
> `pantalla_alarma_sonando.dart` + test files). This is pre-existing formatter-version drift already
> present on `main` before this change started, unrelated to Slice 2 or Slice 3 — worth a
> follow-up `dart format .` cleanup commit on its own, out of scope here.
+18
View File
@@ -30,6 +30,18 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
/// 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);
@@ -64,6 +76,12 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Future<void> confirmarAudioFlutter(String alarmaId) async {
if (puertaConfirmarAudioFlutter != null) {
await puertaConfirmarAudioFlutter!.future;
}
if (fallaConfirmarAudioFlutter) {
throw StateError('fake confirmarAudioFlutter failure');
}
detenidas.add(alarmaId);
}
@@ -31,6 +31,12 @@ class _Entorno {
Future<_Entorno> _montarPantalla(
WidgetTester tester, {
int snoozeMinutos = 5,
// 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;
@@ -38,7 +44,9 @@ Future<_Entorno> _montarPantalla(
addTearDown(tester.view.resetDevicePixelRatio);
final audio = FakeServicioAudio();
audio.emitirEstado(EstadoReproduccion.reproduciendo);
if (audioYaReproduciendo) {
audio.emitirEstado(EstadoReproduccion.reproduciendo);
}
final radio = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
@@ -158,4 +166,66 @@ void main() {
);
},
);
group('fade-in dedup en el handoff (Slice 3)', () {
testWidgets('el fade-in de Dart se retiene hasta que el nativo confirma el '
'handoff, y arranca justo despues (camino radio)', (tester) async {
final entorno = await _montarPantalla(
tester,
audioYaReproduciendo: false,
);
entorno.android.puertaConfirmarAudioFlutter = Completer<void>();
// Antes de que la radio confirme "reproduciendo", solo debe existir
// el volumen de arranque previo (0.05): el ramp real hacia
// alarma.volumen todavia NO debe haber arrancado.
expect(entorno.android.detenidas, isEmpty);
expect(entorno.audio.volumenesAplicados, [0.05]);
// La radio confirma que esta reproduciendo -> dispara
// _confirmarAudioFlutterListo(), que queda bloqueado en la puerta
// (todavia no hay confirmacion nativa real).
entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo);
await tester.pumpAndSettle();
expect(
entorno.android.detenidas,
isEmpty,
reason: 'confirmarAudioFlutter sigue bloqueado en la puerta de prueba',
);
expect(
entorno.audio.volumenesAplicados,
[0.05],
reason: 'el fade-in de Dart no debe arrancar antes del handoff',
);
// Se libera la puerta: recien ahi "confirma" el nativo, y solo
// entonces debe arrancar el fade-in de Dart (una unica rampa
// audible a la vez).
entorno.android.puertaConfirmarAudioFlutter!.complete();
await tester.pumpAndSettle();
expect(entorno.android.detenidas, contains('ring1'));
expect(entorno.audio.volumenesAplicados, [0.05, 0.85]);
});
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, 0.85]);
});
});
}