fix(alarm): handle snooze reschedule failures instead of silently dropping them
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m1s

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:
2026-07-01 00:22:58 +02:00
parent cc98f3f331
commit 6acbd7ca93
6 changed files with 263 additions and 32 deletions
@@ -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);
},
);
});