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
-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?>{});
},
);
}