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