fix(alarm): handle snooze reschedule failures instead of silently dropping them
posponerAlarma() and posponerProximaDesdePreaviso() called the native scheduler with no error handling, unlike guardarAlarma(). When the native call failed (e.g. revoked exact-alarm permission), the exception escaped before notifyListeners() ran, leaving the alarm list stuck on stale data with no real alarm scheduled and no snooze countdown notification. Both methods now mirror guardarAlarma()'s pattern: permission pre-check, try/catch into _error, and an unconditional notifyListeners() so the UI always reflects the outcome. Failures surface via SnackBar in the ringing screen and in app.dart's postpone-next handler.
This commit is contained in:
+7
-1
@@ -300,10 +300,16 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _indice = 3);
|
||||
// posponerProximaDesdePreaviso no longer throws on a native scheduling
|
||||
// failure — it records the failure into EstadoAlarmas.error instead.
|
||||
// Branch on it here so the user sees the real outcome instead of an
|
||||
// always-success message.
|
||||
final error = estado.error;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).alarmPostponedCurrentExecution,
|
||||
error ??
|
||||
AppLocalizations.of(context).alarmPostponedCurrentExecution,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -192,6 +192,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
||||
_error = null;
|
||||
final ejecucion =
|
||||
alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now();
|
||||
debugPrint(
|
||||
@@ -205,8 +206,14 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
);
|
||||
_aplicar(config);
|
||||
final actualizada = _buscarAlarma(alarma.id);
|
||||
if (actualizada != null) {
|
||||
await android.programar(actualizada);
|
||||
try {
|
||||
if (actualizada != null) {
|
||||
await _solicitarPermisosNecesariosParaAlarma();
|
||||
await android.programar(actualizada);
|
||||
}
|
||||
} catch (e) {
|
||||
_error =
|
||||
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -216,6 +223,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
int minutos,
|
||||
DateTime ejecucion,
|
||||
) async {
|
||||
_error = null;
|
||||
final seguros = _snoozeSeguro(minutos);
|
||||
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
|
||||
debugPrint(
|
||||
@@ -229,8 +237,14 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
);
|
||||
_aplicar(config);
|
||||
final actualizada = _buscarAlarma(alarma.id);
|
||||
if (actualizada != null) {
|
||||
await android.programar(actualizada);
|
||||
try {
|
||||
if (actualizada != null) {
|
||||
await _solicitarPermisosNecesariosParaAlarma();
|
||||
await android.programar(actualizada);
|
||||
}
|
||||
} catch (e) {
|
||||
_error =
|
||||
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -173,13 +173,23 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
Future<void> _posponer(int minutos) async {
|
||||
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);
|
||||
// See _detener: the screen MUST close even if posponerAlarma throws
|
||||
// (e.g. native scheduleAlarm returns false on a device without exact
|
||||
// alarm permission). Otherwise the modal freezes and the snooze never
|
||||
// re-rings because _alarmaSonandoActiva stays true.
|
||||
// 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
|
||||
// structural safety net, not a workaround for an expected throw. The
|
||||
// screen still closes either way (dismiss-by-design); the failure is
|
||||
// reported to the user via a SnackBar, not silently swallowed.
|
||||
try {
|
||||
await alarmas.posponerAlarma(widget.alarma, minutos);
|
||||
final error = alarmas.error;
|
||||
if (error != null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] posponer alarma fallo: $e');
|
||||
} finally {
|
||||
|
||||
@@ -249,6 +249,119 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('posponerAlarma: cuando android.programar falla, no relanza, notifica y '
|
||||
'registra el error (sin corromper el estado en memoria)', () async {
|
||||
final ahora = DateTime(2026, 6, 11, 7, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(alarmaDiaria('fail1'));
|
||||
final alarma = estado.alarmas.single;
|
||||
|
||||
android.fallaProgramar = true;
|
||||
var notificaciones = 0;
|
||||
estado.addListener(() => notificaciones++);
|
||||
|
||||
await estado.posponerAlarma(alarma, 5);
|
||||
|
||||
expect(estado.error, isNotNull);
|
||||
expect(notificaciones, greaterThanOrEqualTo(1));
|
||||
expect(
|
||||
estado.alarmas.single.snoozeHasta,
|
||||
DateTime(2026, 6, 11, 7, 35),
|
||||
reason: 'el estado en memoria se aplica antes de la llamada nativa',
|
||||
);
|
||||
});
|
||||
|
||||
test('posponerAlarma: tras un fallo previo, un reintento exitoso limpia '
|
||||
'estado.error (D5)', () async {
|
||||
final ahora = DateTime(2026, 6, 11, 7, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(alarmaDiaria('fail2'));
|
||||
final alarma = estado.alarmas.single;
|
||||
|
||||
android.fallaProgramar = true;
|
||||
await estado.posponerAlarma(alarma, 5);
|
||||
expect(estado.error, isNotNull);
|
||||
|
||||
android.fallaProgramar = false;
|
||||
await estado.posponerAlarma(estado.alarmas.single, 5);
|
||||
|
||||
expect(estado.error, isNull);
|
||||
});
|
||||
|
||||
test('posponerProximaDesdePreaviso: cuando android.programar falla, no '
|
||||
'relanza, notifica y registra el error (sin corromper el estado en '
|
||||
'memoria)', () async {
|
||||
final ahora = DateTime(2026, 6, 11, 7, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(alarmaDiaria('preaviso-fail1'));
|
||||
final alarma = estado.alarmas.single;
|
||||
final ejecucion = alarma.proximaEjecucion!;
|
||||
|
||||
android.fallaProgramar = true;
|
||||
var notificaciones = 0;
|
||||
estado.addListener(() => notificaciones++);
|
||||
|
||||
await estado.posponerProximaDesdePreaviso(alarma, 5, ejecucion);
|
||||
|
||||
expect(estado.error, isNotNull);
|
||||
expect(notificaciones, greaterThanOrEqualTo(1));
|
||||
expect(
|
||||
estado.alarmas.single.snoozeHasta,
|
||||
ejecucion.add(const Duration(minutes: 5)),
|
||||
reason: 'el estado en memoria se aplica antes de la llamada nativa',
|
||||
);
|
||||
});
|
||||
|
||||
test('posponerProximaDesdePreaviso: tras un fallo previo, un reintento '
|
||||
'exitoso limpia estado.error (D5)', () async {
|
||||
final ahora = DateTime(2026, 6, 11, 7, 0);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => ahora),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(alarmaDiaria('preaviso-fail2'));
|
||||
final alarma = estado.alarmas.single;
|
||||
final ejecucion = alarma.proximaEjecucion!;
|
||||
|
||||
android.fallaProgramar = true;
|
||||
await estado.posponerProximaDesdePreaviso(alarma, 5, ejecucion);
|
||||
expect(estado.error, isNotNull);
|
||||
|
||||
android.fallaProgramar = false;
|
||||
await estado.posponerProximaDesdePreaviso(
|
||||
estado.alarmas.single,
|
||||
5,
|
||||
ejecucion,
|
||||
);
|
||||
|
||||
expect(estado.error, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'evento nativo snoozeCancelled limpia el snooze y avanza sin reprogramar',
|
||||
() async {
|
||||
|
||||
@@ -17,6 +17,11 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
bool ignoraOptimizacionBateria = true;
|
||||
int solicitudesExencionBateria = 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;
|
||||
|
||||
/// Simulates a native -> Flutter `alarmFired` MethodChannel event.
|
||||
void emitirEvento(EventoAlarmaAndroid evento) => _eventos.add(evento);
|
||||
|
||||
@@ -28,6 +33,9 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
|
||||
@override
|
||||
Future<void> programar(AlarmaMusical alarma) async {
|
||||
if (fallaProgramar) {
|
||||
throw StateError('fake programar failure');
|
||||
}
|
||||
programadas.add(alarma);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,15 +23,12 @@ class _SystemNavigatorSpy {
|
||||
|
||||
void install() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'SystemNavigator.pop') {
|
||||
popCalls++;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'SystemNavigator.pop') {
|
||||
popCalls++;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
void uninstall() {
|
||||
@@ -94,7 +91,13 @@ Future<void> _montarConHistorial(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const SizedBox.shrink(),
|
||||
// A real Scaffold (not a bare SizedBox) is required here: Flutter's
|
||||
// ScaffoldMessenger only displays a SnackBar through a currently
|
||||
// registered ScaffoldState. app.dart's root _PaginaPrincipal is
|
||||
// always a Scaffold (via PluriWaveScaffold), so this mirrors
|
||||
// production — without it, the failure SnackBar would vanish the
|
||||
// instant the ringing screen's own Scaffold pops off the tree.
|
||||
home: const Scaffold(body: SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -102,10 +105,11 @@ Future<void> _montarConHistorial(
|
||||
unawaited(
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
builder:
|
||||
(_) => PantallaAlarmaSonando(
|
||||
alarma: estadoAlarmas.alarmas.single,
|
||||
audioPrearrancado: true,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
),
|
||||
@@ -113,7 +117,7 @@ Future<void> _montarConHistorial(
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<_Env> _buildEnv() async {
|
||||
Future<_Env> _buildEnv({bool fallaProgramar = false}) async {
|
||||
final audio = FakeServicioAudio();
|
||||
audio.emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
final radio = EstadoRadio(
|
||||
@@ -146,6 +150,9 @@ Future<_Env> _buildEnv() async {
|
||||
),
|
||||
),
|
||||
);
|
||||
// Failure mode must be enabled AFTER the initial save above, since the
|
||||
// initial guardarAlarma must succeed for the env to be usable.
|
||||
android.fallaProgramar = fallaProgramar;
|
||||
return _Env(radio: radio, android: android, estadoAlarmas: estadoAlarmas);
|
||||
}
|
||||
|
||||
@@ -198,8 +205,11 @@ void main() {
|
||||
// Screen should be dismissed via Navigator.pop (stack pop)
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
// SystemNavigator.pop must NOT have been called
|
||||
expect(spy.popCalls, 0,
|
||||
reason: 'SystemNavigator.pop must not be called when canPop is true');
|
||||
expect(
|
||||
spy.popCalls,
|
||||
0,
|
||||
reason: 'SystemNavigator.pop must not be called when canPop is true',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -224,8 +234,11 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// SystemNavigator.pop must be called exactly once
|
||||
expect(spy.popCalls, 1,
|
||||
reason: 'SystemNavigator.pop must be called when canPop is false');
|
||||
expect(
|
||||
spy.popCalls,
|
||||
1,
|
||||
reason: 'SystemNavigator.pop must be called when canPop is false',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -248,8 +261,11 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
expect(spy.popCalls, 0,
|
||||
reason: 'SystemNavigator.pop must not be called when canPop is true');
|
||||
expect(
|
||||
spy.popCalls,
|
||||
0,
|
||||
reason: 'SystemNavigator.pop must not be called when canPop is true',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -271,8 +287,72 @@ void main() {
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(spy.popCalls, 1,
|
||||
reason: 'SystemNavigator.pop must be called when canPop is false');
|
||||
expect(
|
||||
spy.popCalls,
|
||||
1,
|
||||
reason: 'SystemNavigator.pop must be called when canPop is false',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('PantallaAlarmaSonando snooze failure feedback (Phase 4)', () {
|
||||
testWidgets(
|
||||
'posponer: cuando android.programar falla, se muestra un SnackBar de '
|
||||
'error y la pantalla igual se cierra',
|
||||
(tester) async {
|
||||
final env = await _buildEnv(fallaProgramar: true);
|
||||
addTearDown(env.dispose);
|
||||
|
||||
await _montarConHistorial(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
|
||||
// Pump just enough to let the await chain + dismiss + SnackBar
|
||||
// entrance animation settle, without using pumpAndSettle (the
|
||||
// SnackBar's default 4s display duration would otherwise be pumped
|
||||
// through, dismissing it before the assertion below).
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
// Screen still dismisses (dismiss-by-design preserved).
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
// Failure is surfaced via SnackBar, not silently swallowed.
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
expect(
|
||||
find.text(env.estadoAlarmas.error ?? ''),
|
||||
findsOneWidget,
|
||||
reason: 'el SnackBar debe mostrar el texto de estado.error',
|
||||
);
|
||||
|
||||
// Let pending timers (SnackBar auto-dismiss) drain before teardown.
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'posponer: cuando tiene exito, no se muestra SnackBar de error',
|
||||
(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(find.byType(SnackBar), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user