diff --git a/lib/app.dart b/lib/app.dart index 52fb6ab..5c507dd 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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, ), ), ); diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 56a3d30..2584d76 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -192,6 +192,7 @@ class EstadoAlarmas extends ChangeNotifier { } Future 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(); } diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 6d85836..3c2ad43 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -173,13 +173,23 @@ class _PantallaAlarmaSonandoState extends State { Future _posponer(int minutos) async { final radio = context.read(); final alarmas = context.read(); + // 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 { diff --git a/test/estado/estado_alarmas_snooze_test.dart b/test/estado/estado_alarmas_snooze_test.dart index dde593b..f97cb3a 100644 --- a/test/estado/estado_alarmas_snooze_test.dart +++ b/test/estado/estado_alarmas_snooze_test.dart @@ -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 { diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 15fa439..8bfa659 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -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 programar(AlarmaMusical alarma) async { + if (fallaProgramar) { + throw StateError('fake programar failure'); + } programadas.add(alarma); } diff --git a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart index 38a3da9..d145e29 100644 --- a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart @@ -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 _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 _montarConHistorial( unawaited( navigator.push( MaterialPageRoute( - builder: (_) => PantallaAlarmaSonando( - alarma: estadoAlarmas.alarmas.single, - audioPrearrancado: true, - ), + builder: + (_) => PantallaAlarmaSonando( + alarma: estadoAlarmas.alarmas.single, + audioPrearrancado: true, + ), fullscreenDialog: true, ), ), @@ -113,7 +117,7 @@ Future _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); }, ); });