fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes, only uninstall silenced it) plus systematic hardening of every stop path. Native (Kotlin): - Verified stop: stopActiveAlarm now derives its result from the real post-teardown state (companion instance + synchronous stopEverything + activeRingingId check) instead of reporting unconditional success. - Atomic teardown: every stop path (stop action, notification button, snooze, missed, onDestroy, startForeground failure) funnels through one stopEverything() covering audio, wakelock, notification, foreground state and firing-record cleanup; player.release() guarded. - Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a FIRED->MISSED transition with a localized missed-alarm notification; repeating alarms keep their native rearm, deleted alarms never produce ghost MISSED notifications. - Durable firing record with onStartCommand re-validation (resurrection guard) and boot-time stale cleanup; firing records cleared on every refuse/mismatch/cancel path. - New notification-only dismissal channel (dismissAlarmNotificationOnly) so UI-level dedup can never kill a live ring's audio. Flutter (Dart): - Stop/disable/edit/delete of a ringing alarm always attempt to silence it; on native-query failure the stop falls back toward silence via the id-scoped legacy stop. - Verified-stop results surface failures: the ringing screen keeps dismiss-by-design on success, but on a verified failure it stays up with a persistent force-stop banner (guarded against double-dismiss) and auto-dismisses if the ring ends externally (missed/notification). - Missed events sync alarm bookkeeping without opening the ringing UI. - 4 new l10n keys translated across all 13 locales (ARB guard green). 550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds (2 deterministic + 1 refuter-corroborated critical fixed); formal gentle-ai receipt waived by maintainer authorization (correction scope legitimately exceeded the frozen genesis paths). On-device QA checklist in openspec/changes/alarm-system-overhaul/tasks.md pending before archive.
This commit is contained in:
@@ -30,9 +30,31 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
/// (a second _dismissScreen would pop the route UNDER the alarm screen).
|
||||
bool _salidaEnCurso = false;
|
||||
|
||||
/// Retryable force-stop affordance (Finding A, spec `alarm-stop-safety` /
|
||||
/// "Retryable Force-Stop Affordance"): true while a VERIFIED stop failure
|
||||
/// (or an unknown-state exception) is outstanding. Unlike a timed SnackBar,
|
||||
/// this drives a persistent in-screen banner that stays until a confirmed
|
||||
/// stop clears it — the ring is still audible while this is true, so the
|
||||
/// screen intentionally does NOT dismiss.
|
||||
bool _falloDetencionVisible = false;
|
||||
|
||||
late final EstadoAlarmas _alarmas;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_alarmas = context.read<EstadoAlarmas>();
|
||||
_alarmas.addListener(_alReconciliarFinExterno);
|
||||
}
|
||||
|
||||
/// External end-of-ring reconciliation (RES-1): if this alarm's occurrence
|
||||
/// gets recorded as MISSED while this screen is up, auto-dismiss instead of
|
||||
/// leaving a stale ringing screen with no audio behind it.
|
||||
void _alReconciliarFinExterno() {
|
||||
if (_salidaEnCurso || !mounted) return;
|
||||
if (_alarmas.ultimaAlarmaPerdidaId != widget.alarma.id) return;
|
||||
_salidaEnCurso = true;
|
||||
_dismissScreen();
|
||||
}
|
||||
|
||||
/// Pure UI: the ring's audio is owned entirely by the native
|
||||
@@ -43,15 +65,46 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
if (_salidaEnCurso) return;
|
||||
_salidaEnCurso = true;
|
||||
final alarmas = context.read<EstadoAlarmas>();
|
||||
// Dismiss is run from finally so a failing reschedule/teardown can never
|
||||
// leave the ringing screen stuck open (which would also block the next
|
||||
// ring via the _alarmaSonandoActiva guard in app.dart).
|
||||
final alarmaId = widget.alarma.id;
|
||||
try {
|
||||
await alarmas.finalizarEjecucion(widget.alarma.id);
|
||||
await alarmas.finalizarEjecucion(alarmaId);
|
||||
if (alarmas.error != null) {
|
||||
// Verified stop failure (Finding A): the alarm is still ringing, so
|
||||
// dismissing now would hide the only retry affordance. Reset the
|
||||
// single-exit guard so a retry (this button again, back gesture, or
|
||||
// the banner's own action below) can run the teardown again.
|
||||
_salidaEnCurso = false;
|
||||
if (mounted) setState(() => _falloDetencionVisible = true);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
|
||||
} finally {
|
||||
// Unknown state (Finding A): treat exactly like a verified failure —
|
||||
// stay and show the retry banner. The notification's native Stop
|
||||
// action remains the out-of-band fallback, and PopScope already routes
|
||||
// back through this same method on a subsequent back-press.
|
||||
_salidaEnCurso = false;
|
||||
if (mounted) setState(() => _falloDetencionVisible = true);
|
||||
return;
|
||||
}
|
||||
if (mounted) _dismissScreen();
|
||||
}
|
||||
|
||||
/// Retry action bound to the persistent force-stop banner (Finding A,
|
||||
/// SS-3b): re-invokes the fail-safe stop directly; dismisses ONLY on a
|
||||
/// confirmed success, otherwise the banner stays exactly as it was.
|
||||
Future<void> _forzarDetencion() async {
|
||||
if (_salidaEnCurso) return;
|
||||
_salidaEnCurso = true;
|
||||
final alarmas = context.read<EstadoAlarmas>();
|
||||
await alarmas.forzarDetencion(widget.alarma.id);
|
||||
if (alarmas.error == null) {
|
||||
if (mounted) _dismissScreen();
|
||||
} else {
|
||||
// Verified failure (RES-2): reset the guard so the banner's own retry
|
||||
// action (or another button) can run the teardown again.
|
||||
_salidaEnCurso = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +162,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_alarmas.removeListener(_alReconciliarFinExterno);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -209,6 +263,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
label: Text(l10n.stopAlarmAction),
|
||||
),
|
||||
if (_falloDetencionVisible) ...[
|
||||
const SizedBox(height: 14),
|
||||
_bannerFalloDetencion(context, l10n, tokens),
|
||||
],
|
||||
],
|
||||
),
|
||||
).pluriFadeIn(context),
|
||||
@@ -217,6 +275,37 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persistent force-stop retry banner (Finding A, spec `alarm-stop-safety`
|
||||
/// / "Retryable Force-Stop Affordance"): an in-screen section rather than a
|
||||
/// timed SnackBar, so it stays visible until [_forzarDetencion] confirms a
|
||||
/// stop (or the screen is torn down externally) instead of auto-dismissing
|
||||
/// after a fixed duration.
|
||||
Widget _bannerFalloDetencion(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
PluriWaveTokens tokens,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.warmCoral.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.alarmStopFailedMessage, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _forzarDetencion,
|
||||
child: Text(l10n.alarmForceStopAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _hora(AlarmaMusical alarma) =>
|
||||
|
||||
Reference in New Issue
Block a user