fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s

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:
Javier Bautista Fernández
2026-07-22 23:52:36 +02:00
parent 0f9a6a1719
commit 29f7d54e85
50 changed files with 2461 additions and 24 deletions
+13 -8
View File
@@ -281,6 +281,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
// nothing to open for this event.
return;
}
if (evento.accion == EventoAlarmaAndroid.accionMissed) {
// EstadoAlarmas' own native-event listener already recorded this
// transition (RES-1); the ring already ended, so opening the ringing
// screen here would only show a stale, already-silent alarm.
return;
}
final estado = context.read<EstadoAlarmas>();
if (estado.alarmas.isEmpty) {
await estado.cargarPersistidasSinRecalcular();
@@ -361,15 +367,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
);
// A duplicate delivery of the SAME ring's own fire event (the live
// eventosAlarma stream and the one-shot obtenerEventoInicial() read
// the same native event and can both reach here) must be a no-op:
// ocultarNotificacionAlarma -> dismissAlarmNotification unconditionally
// stops PluriWaveAlarmService for that id on the native side, which
// would tear down the currently-ringing service and undo the
// ring-scoped media-volume override long before the real handoff.
// Only hide the notification when a genuinely DIFFERENT alarm fired
// while this one is active (single-ring-at-a-time by design).
// the same native event and can both reach here) must be a no-op.
// When a genuinely DIFFERENT alarm fired while this one is active
// (single-ring-at-a-time by design), hide ONLY its notification
// (RES-1): ocultarNotificacionAlarma -> dismissAlarmNotification
// unconditionally stops PluriWaveAlarmService, which would silently
// kill the OTHER alarm's ring if it is the one genuinely sounding.
if (alarma.id != _alarmaSonandoId) {
await alarmas.android.ocultarNotificacionAlarma(alarma.id);
await alarmas.android.ocultarSoloNotificacion(alarma.id);
}
return;
}
+88 -1
View File
@@ -54,6 +54,10 @@ class EstadoAlarmas extends ChangeNotifier {
bool _cargando = false;
String? _error;
/// Last alarm id recorded as MISSED (RES-1): lets the ringing screen
/// detect an external end-of-ring for its own alarm and reconcile.
String? ultimaAlarmaPerdidaId;
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
@@ -100,6 +104,10 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint(
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
);
// Mutation-while-ringing stop guard (SS-1a/SS-1b): fires BEFORE the save
// persists so an edit/toggle-off of the currently-ringing alarm always
// silences it first.
await _detenerSiEstaSonando(alarma.id);
final config = await servicio.guardarAlarma(alarma);
_aplicar(config);
try {
@@ -156,11 +164,40 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] eliminar id=$id');
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
await android.detenerSonidoNativo(id);
// Deleting the ringing alarm stops audio (SS-1c, regression lock): the
// centralized guard runs before cancelar, same as guardarAlarma.
await _detenerSiEstaSonando(id);
await android.cancelar(id);
notifyListeners();
}
/// Centralized mutation-while-ringing stop guard (Decision 5): every
/// mutation of the currently-ringing alarm routes through this ONE check
/// instead of per-call-site logic, so a mutation of a DIFFERENT (non-
/// ringing) alarm never touches the live ring (SS-1d).
Future<void> _detenerSiEstaSonando(String id) async {
try {
final sonando = await android.alarmaSonandoId();
if (sonando == id) {
await android.detenerSonidoActivo();
}
} catch (e) {
debugPrint('[PluriWave][alarmas] detenerSiEstaSonando ERROR $e');
// Fail-toward-silence (Finding 2, eliminarAlarma regression): a failed
// query must not silently skip the stop when the alarm might genuinely
// be ringing. Fall back to the id-scoped legacy stop (the native side
// no-ops safely on a mismatch) inside its own try/catch so this outer
// flow (guardarAlarma/eliminarAlarma) always proceeds regardless.
try {
await android.detenerSonidoNativo(id);
} catch (fallbackError) {
debugPrint(
'[PluriWave][alarmas] detenerSiEstaSonando fallback ERROR $fallbackError',
);
}
}
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
@@ -271,6 +308,7 @@ class EstadoAlarmas extends ChangeNotifier {
Future<void> finalizarEjecucion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
_error = null;
final alarma = _buscarAlarma(alarmaId);
final ejecucion =
alarma?.snoozeOrigen ??
@@ -278,12 +316,34 @@ class EstadoAlarmas extends ChangeNotifier {
alarma?.snoozeHasta ??
DateTime.now();
await android.ocultarNotificacionAlarma(alarmaId);
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
// id-agnostic fail-safe stop directly (it always targets whatever is
// ringing). `detenido` reflects the VERIFIED native teardown state
// (activeRingingId cleared same-process after a synchronous stop), not a
// literal dispatch acknowledgement, so a genuine failure is never
// swallowed.
final resultado = await android.detenerSonidoActivo();
if (!resultado.detenido) {
_error = 'No se pudo confirmar que la alarma dejo de sonar.';
}
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
/// Retryable force-stop affordance (SS-3b): re-invokes the same fail-safe
/// stop; success clears the recorded failure, another failure keeps it.
Future<void> forzarDetencion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] forzar detencion id=$alarmaId');
final resultado = await android.detenerSonidoActivo();
_error =
resultado.detenido
? null
: 'No se pudo detener la alarma. Intentalo de nuevo.';
notifyListeners();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [..._vacaciones, rango];
await guardarVacaciones(nuevos);
@@ -319,6 +379,10 @@ class EstadoAlarmas extends ChangeNotifier {
await _registrarCancelacionSnoozeNativa(evento);
return;
}
if (evento.accion == EventoAlarmaAndroid.accionMissed) {
await _registrarEjecucionPerdida(evento);
return;
}
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
@@ -366,6 +430,29 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
/// Records a native auto-silence (MISSED) transition (Phase 6): the native
/// scheduler already rearmed the next occurrence (repeating) or left it
/// disabled (one-shot) at fire time, so this only marks the occurrence
/// handled -- it MUST NOT call android.programar again.
Future<void> _registrarEjecucionPerdida(EventoAlarmaAndroid evento) async {
if (evento.alarmaId.isEmpty) return;
final origen =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: DateTime.now();
debugPrint(
'[PluriWave][alarmas] ejecucion perdida id=${evento.alarmaId} origen=${origen.toIso8601String()}',
);
try {
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
_aplicar(config);
ultimaAlarmaPerdidaId = evento.alarmaId;
notifyListeners();
} catch (e) {
debugPrint('[PluriWave][alarmas] ejecucion perdida ERROR $e');
}
}
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "يرن باستخدام الصوت الداخلي الآمن.",
"ringingPreparingInternalAudio": "جارٍ تجهيز الصوت الداخلي الآمن.",
"stopAlarmAction": "إيقاف المنبه",
"alarmStopFailedMessage": "تعذّر التأكد من إيقاف المنبه. حاول مرة أخرى.",
"alarmForceStopAction": "إيقاف قسري",
"alarmMissedNotificationTitle": "منبه فائت",
"alarmMissedNotificationText": "تم كتم {name} تلقائيًا بعد 10 دقائق.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "إيقاف مؤقت",
"miniPlayerOpenLabel": "فتح المشغل لـ {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "নিরাপদ অভ্যন্তরীণ শব্দ দিয়ে বাজছে।",
"ringingPreparingInternalAudio": "নিরাপদ অভ্যন্তরীণ শব্দ প্রস্তুত হচ্ছে।",
"stopAlarmAction": "অ্যালার্ম বন্ধ করুন",
"alarmStopFailedMessage": "অ্যালার্ম বন্ধ হয়েছে তা নিশ্চিত করা যায়নি। আবার চেষ্টা করুন।",
"alarmForceStopAction": "জোর করে বন্ধ করুন",
"alarmMissedNotificationTitle": "মিস হওয়া অ্যালার্ম",
"alarmMissedNotificationText": "১০ মিনিট পর {name} স্বয়ংক্রিয়ভাবে নিঃশব্দ করা হয়েছে।",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "বিরতি দিন",
"miniPlayerOpenLabel": "{stationName}-এর প্লেয়ার খুলুন",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Wiedergabe mit internem Sicherheitston.",
"ringingPreparingInternalAudio": "Interner Sicherheitston wird vorbereitet.",
"stopAlarmAction": "Alarm stoppen",
"alarmStopFailedMessage": "Wir konnten nicht bestätigen, dass der Alarm gestoppt wurde. Versuche es erneut.",
"alarmForceStopAction": "Erzwungen stoppen",
"alarmMissedNotificationTitle": "Verpasster Alarm",
"alarmMissedNotificationText": "{name} wurde nach 10 Minuten automatisch stummgeschaltet.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Pausieren",
"miniPlayerOpenLabel": "Wiedergabe für {stationName} öffnen",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Playing with internal safe audio.",
"ringingPreparingInternalAudio": "Preparing internal safe audio.",
"stopAlarmAction": "Stop alarm",
"alarmStopFailedMessage": "We couldn't confirm the alarm stopped. Try again.",
"alarmForceStopAction": "Force stop",
"alarmMissedNotificationTitle": "Missed alarm",
"alarmMissedNotificationText": "{name} was silenced automatically after 10 minutes.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Pause",
"miniPlayerOpenLabel": "Open player for {stationName}",
"@miniPlayerOpenLabel": {
+5
View File
@@ -466,6 +466,11 @@
"ringingInternalAudioActive": "Sonando con audio seguro interno.",
"ringingPreparingInternalAudio": "Preparando audio seguro interno.",
"stopAlarmAction": "Detener alarma",
"alarmStopFailedMessage": "No pudimos confirmar que la alarma se detuvo. Inténtalo de nuevo.",
"alarmForceStopAction": "Forzar detención",
"alarmMissedNotificationTitle": "Alarma perdida",
"alarmMissedNotificationText": "{name} se silenció automáticamente después de 10 minutos.",
"@alarmMissedNotificationText": {"placeholders": {"name": {}}},
"pauseAction": "Pausar",
"miniPlayerOpenLabel": "Abrir reproductor de {stationName}",
"@miniPlayerOpenLabel": {"placeholders": {"stationName": {}}},
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Son sécurisé interne en cours.",
"ringingPreparingInternalAudio": "Préparation du son sécurisé interne.",
"stopAlarmAction": "Arrêter lalarme",
"alarmStopFailedMessage": "Nous navons pas pu confirmer larrêt de lalarme. Réessayez.",
"alarmForceStopAction": "Forcer larrêt",
"alarmMissedNotificationTitle": "Alarme manquée",
"alarmMissedNotificationText": "{name} a été mise en sourdine automatiquement après 10 minutes.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Pause",
"miniPlayerOpenLabel": "Ouvrir le lecteur de {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "सुरक्षित आंतरिक ध्वनि के साथ बज रहा है।",
"ringingPreparingInternalAudio": "सुरक्षित आंतरिक ध्वनि तैयार हो रही है।",
"stopAlarmAction": "अलार्म रोकें",
"alarmStopFailedMessage": "हम पुष्टि नहीं कर सके कि अलार्म बंद हुआ। फिर से कोशिश करें।",
"alarmForceStopAction": "जबरन बंद करें",
"alarmMissedNotificationTitle": "छूटा हुआ अलार्म",
"alarmMissedNotificationText": "10 मिनट बाद {name} अपने आप म्यूट कर दिया गया।",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "विराम दें",
"miniPlayerOpenLabel": "{stationName} का प्लेयर खोलें",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Berbunyi dengan audio internal aman.",
"ringingPreparingInternalAudio": "Menyiapkan audio internal aman.",
"stopAlarmAction": "Hentikan alarm",
"alarmStopFailedMessage": "Kami tidak dapat memastikan alarm berhenti. Coba lagi.",
"alarmForceStopAction": "Paksa berhenti",
"alarmMissedNotificationTitle": "Alarm terlewat",
"alarmMissedNotificationText": "{name} dibisukan secara otomatis setelah 10 menit.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Jeda",
"miniPlayerOpenLabel": "Buka pemutar untuk {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Suono sicuro interno in riproduzione.",
"ringingPreparingInternalAudio": "Preparazione del suono sicuro interno.",
"stopAlarmAction": "Ferma sveglia",
"alarmStopFailedMessage": "Non siamo riusciti a confermare larresto della sveglia. Riprova.",
"alarmForceStopAction": "Forza arresto",
"alarmMissedNotificationTitle": "Sveglia mancata",
"alarmMissedNotificationText": "{name} è stata disattivata automaticamente dopo 10 minuti.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Pausa",
"miniPlayerOpenLabel": "Apri il lettore per {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "安全な内部音で鳴っています。",
"ringingPreparingInternalAudio": "安全な内部音を準備中です。",
"stopAlarmAction": "アラームを停止",
"alarmStopFailedMessage": "アラームが停止したことを確認できませんでした。もう一度お試しください。",
"alarmForceStopAction": "強制停止",
"alarmMissedNotificationTitle": "アラームの聞き逃し",
"alarmMissedNotificationText": "{name}は10分後に自動的に消音されました。",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "一時停止",
"miniPlayerOpenLabel": "{stationName}のプレーヤーを開く",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Tocando com som seguro interno.",
"ringingPreparingInternalAudio": "Preparando som seguro interno.",
"stopAlarmAction": "Parar alarme",
"alarmStopFailedMessage": "Não conseguimos confirmar que o alarme parou. Tente novamente.",
"alarmForceStopAction": "Forçar parada",
"alarmMissedNotificationTitle": "Alarme perdido",
"alarmMissedNotificationText": "{name} foi silenciado automaticamente após 10 minutos.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Pausar",
"miniPlayerOpenLabel": "Abrir reprodutor de {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Звонит с безопасным внутренним звуком.",
"ringingPreparingInternalAudio": "Подготовка безопасного внутреннего звука.",
"stopAlarmAction": "Остановить будильник",
"alarmStopFailedMessage": "Не удалось подтвердить, что будильник остановлен. Попробуйте снова.",
"alarmForceStopAction": "Принудительно остановить",
"alarmMissedNotificationTitle": "Пропущенный будильник",
"alarmMissedNotificationText": "{name} был автоматически отключён через 10 минут.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Пауза",
"miniPlayerOpenLabel": "Открыть плеер для {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "正在使用内部安全音频响铃。",
"ringingPreparingInternalAudio": "正在准备内部安全音频。",
"stopAlarmAction": "停止闹钟",
"alarmStopFailedMessage": "无法确认闹钟已停止,请重试。",
"alarmForceStopAction": "强制停止",
"alarmMissedNotificationTitle": "错过的闹钟",
"alarmMissedNotificationText": "{name}已在10分钟后自动静音。",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "暂停",
"miniPlayerOpenLabel": "打开 {stationName} 的播放器",
"@miniPlayerOpenLabel": {
+24
View File
@@ -1694,6 +1694,30 @@ abstract class AppLocalizations {
/// **'Detener alarma'**
String get stopAlarmAction;
/// No description provided for @alarmStopFailedMessage.
///
/// In es, this message translates to:
/// **'No pudimos confirmar que la alarma se detuvo. Inténtalo de nuevo.'**
String get alarmStopFailedMessage;
/// No description provided for @alarmForceStopAction.
///
/// In es, this message translates to:
/// **'Forzar detención'**
String get alarmForceStopAction;
/// No description provided for @alarmMissedNotificationTitle.
///
/// In es, this message translates to:
/// **'Alarma perdida'**
String get alarmMissedNotificationTitle;
/// No description provided for @alarmMissedNotificationText.
///
/// In es, this message translates to:
/// **'{name} se silenció automáticamente después de 10 minutos.'**
String alarmMissedNotificationText(Object name);
/// No description provided for @pauseAction.
///
/// In es, this message translates to:
+15
View File
@@ -899,6 +899,21 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get stopAlarmAction => 'إيقاف المنبه';
@override
String get alarmStopFailedMessage =>
'تعذّر التأكد من إيقاف المنبه. حاول مرة أخرى.';
@override
String get alarmForceStopAction => 'إيقاف قسري';
@override
String get alarmMissedNotificationTitle => 'منبه فائت';
@override
String alarmMissedNotificationText(Object name) {
return 'تم كتم $name تلقائيًا بعد 10 دقائق.';
}
@override
String get pauseAction => 'إيقاف مؤقت';
+15
View File
@@ -908,6 +908,21 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get stopAlarmAction => 'অ্যালার্ম বন্ধ করুন';
@override
String get alarmStopFailedMessage =>
'অ্যালার্ম বন্ধ হয়েছে তা নিশ্চিত করা যায়নি। আবার চেষ্টা করুন।';
@override
String get alarmForceStopAction => 'জোর করে বন্ধ করুন';
@override
String get alarmMissedNotificationTitle => 'মিস হওয়া অ্যালার্ম';
@override
String alarmMissedNotificationText(Object name) {
return '১০ মিনিট পর $name স্বয়ংক্রিয়ভাবে নিঃশব্দ করা হয়েছে।';
}
@override
String get pauseAction => 'বিরতি দিন';
+15
View File
@@ -910,6 +910,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get stopAlarmAction => 'Alarm stoppen';
@override
String get alarmStopFailedMessage =>
'Wir konnten nicht bestätigen, dass der Alarm gestoppt wurde. Versuche es erneut.';
@override
String get alarmForceStopAction => 'Erzwungen stoppen';
@override
String get alarmMissedNotificationTitle => 'Verpasster Alarm';
@override
String alarmMissedNotificationText(Object name) {
return '$name wurde nach 10 Minuten automatisch stummgeschaltet.';
}
@override
String get pauseAction => 'Pausieren';
+15
View File
@@ -903,6 +903,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get stopAlarmAction => 'Stop alarm';
@override
String get alarmStopFailedMessage =>
'We couldn\'t confirm the alarm stopped. Try again.';
@override
String get alarmForceStopAction => 'Force stop';
@override
String get alarmMissedNotificationTitle => 'Missed alarm';
@override
String alarmMissedNotificationText(Object name) {
return '$name was silenced automatically after 10 minutes.';
}
@override
String get pauseAction => 'Pause';
+15
View File
@@ -907,6 +907,21 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get stopAlarmAction => 'Detener alarma';
@override
String get alarmStopFailedMessage =>
'No pudimos confirmar que la alarma se detuvo. Inténtalo de nuevo.';
@override
String get alarmForceStopAction => 'Forzar detención';
@override
String get alarmMissedNotificationTitle => 'Alarma perdida';
@override
String alarmMissedNotificationText(Object name) {
return '$name se silenció automáticamente después de 10 minutos.';
}
@override
String get pauseAction => 'Pausar';
+15
View File
@@ -913,6 +913,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get stopAlarmAction => 'Arrêter lalarme';
@override
String get alarmStopFailedMessage =>
'Nous navons pas pu confirmer larrêt de lalarme. Réessayez.';
@override
String get alarmForceStopAction => 'Forcer larrêt';
@override
String get alarmMissedNotificationTitle => 'Alarme manquée';
@override
String alarmMissedNotificationText(Object name) {
return '$name a été mise en sourdine automatiquement après 10 minutes.';
}
@override
String get pauseAction => 'Pause';
+15
View File
@@ -904,6 +904,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get stopAlarmAction => 'अलार्म रोकें';
@override
String get alarmStopFailedMessage =>
'हम पुष्टि नहीं कर सके कि अलार्म बंद हुआ। फिर से कोशिश करें।';
@override
String get alarmForceStopAction => 'जबरन बंद करें';
@override
String get alarmMissedNotificationTitle => 'छूटा हुआ अलार्म';
@override
String alarmMissedNotificationText(Object name) {
return '10 मिनट बाद $name अपने आप म्यूट कर दिया गया।';
}
@override
String get pauseAction => 'विराम दें';
+15
View File
@@ -908,6 +908,21 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get stopAlarmAction => 'Hentikan alarm';
@override
String get alarmStopFailedMessage =>
'Kami tidak dapat memastikan alarm berhenti. Coba lagi.';
@override
String get alarmForceStopAction => 'Paksa berhenti';
@override
String get alarmMissedNotificationTitle => 'Alarm terlewat';
@override
String alarmMissedNotificationText(Object name) {
return '$name dibisukan secara otomatis setelah 10 menit.';
}
@override
String get pauseAction => 'Jeda';
+15
View File
@@ -910,6 +910,21 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get stopAlarmAction => 'Ferma sveglia';
@override
String get alarmStopFailedMessage =>
'Non siamo riusciti a confermare larresto della sveglia. Riprova.';
@override
String get alarmForceStopAction => 'Forza arresto';
@override
String get alarmMissedNotificationTitle => 'Sveglia mancata';
@override
String alarmMissedNotificationText(Object name) {
return '$name è stata disattivata automaticamente dopo 10 minuti.';
}
@override
String get pauseAction => 'Pausa';
+14
View File
@@ -875,6 +875,20 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get stopAlarmAction => 'アラームを停止';
@override
String get alarmStopFailedMessage => 'アラームが停止したことを確認できませんでした。もう一度お試しください。';
@override
String get alarmForceStopAction => '強制停止';
@override
String get alarmMissedNotificationTitle => 'アラームの聞き逃し';
@override
String alarmMissedNotificationText(Object name) {
return '$nameは10分後に自動的に消音されました';
}
@override
String get pauseAction => '一時停止';
+15
View File
@@ -905,6 +905,21 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get stopAlarmAction => 'Parar alarme';
@override
String get alarmStopFailedMessage =>
'Não conseguimos confirmar que o alarme parou. Tente novamente.';
@override
String get alarmForceStopAction => 'Forçar parada';
@override
String get alarmMissedNotificationTitle => 'Alarme perdido';
@override
String alarmMissedNotificationText(Object name) {
return '$name foi silenciado automaticamente após 10 minutos.';
}
@override
String get pauseAction => 'Pausar';
+15
View File
@@ -909,6 +909,21 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get stopAlarmAction => 'Остановить будильник';
@override
String get alarmStopFailedMessage =>
'Не удалось подтвердить, что будильник остановлен. Попробуйте снова.';
@override
String get alarmForceStopAction => 'Принудительно остановить';
@override
String get alarmMissedNotificationTitle => 'Пропущенный будильник';
@override
String alarmMissedNotificationText(Object name) {
return '$name был автоматически отключён через 10 минут.';
}
@override
String get pauseAction => 'Пауза';
+14
View File
@@ -871,6 +871,20 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get stopAlarmAction => '停止闹钟';
@override
String get alarmStopFailedMessage => '无法确认闹钟已停止,请重试。';
@override
String get alarmForceStopAction => '强制停止';
@override
String get alarmMissedNotificationTitle => '错过的闹钟';
@override
String alarmMissedNotificationText(Object name) {
return '$name已在10分钟后自动静音';
}
@override
String get pauseAction => '暂停';
+94 -5
View File
@@ -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) =>
@@ -27,6 +27,10 @@ class EventoAlarmaAndroid {
/// countdown notification ("Detener" while the app may be killed).
static const accionSnoozeCancelled = 'snoozeCancelled';
/// Action reported when a fired alarm auto-silenced unattended after the
/// 10-minute bound (Decision 3), never a user-initiated stop.
static const accionMissed = 'missed';
final String alarmaId;
final String titulo;
final String accion;
@@ -108,6 +112,36 @@ class DiagnosticoAlarmasAndroid {
}
}
/// Fail-safe stop result (Decision 1). `detenido` reports whether a
/// non-no-op teardown was dispatched (never silently swallowed); `alarmaId`
/// is the id that was actually ringing, for Dart-side reconciliation.
class ResultadoDetencion {
const ResultadoDetencion({
required this.detenido,
required this.estabaSonando,
this.alarmaId,
});
final bool detenido;
final bool estabaSonando;
final String? alarmaId;
/// A thrown channel error or a missing native response is treated as a
/// failure, never as an implicit success.
static const fallo = ResultadoDetencion(
detenido: false,
estabaSonando: false,
);
factory ResultadoDetencion.fromMap(Map<Object?, Object?> map) {
return ResultadoDetencion(
detenido: map['stopped'] as bool? ?? false,
estabaSonando: map['wasRinging'] as bool? ?? false,
alarmaId: map['activeAlarmId'] as String?,
);
}
}
class EjecucionAlarmaNativa {
const EjecucionAlarmaNativa({
required this.alarmaId,
@@ -137,7 +171,20 @@ abstract class PuertoAlarmasAndroid {
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
Future<void> ocultarNotificacionAlarma(String alarmaId);
/// Notification-only dismissal (RES-1): hides the fire notification for
/// [alarmaId] WITHOUT stopping native ring audio for any alarm. Used when a
/// genuinely different alarm rings while another one is still active.
Future<void> ocultarSoloNotificacion(String alarmaId);
Future<void> detenerSonidoNativo(String alarmaId);
/// Synchronous companion snapshot (Decision 1): the id of the alarm
/// currently ringing natively, or null if none is.
Future<String?> alarmaSonandoId();
/// Id-agnostic fail-safe stop: silences whatever is ringing regardless of
/// which alarm the caller thinks is active, and reports a verified result.
Future<ResultadoDetencion> detenerSonidoActivo();
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> solicitarPermisoPantallaCompleta();
@@ -195,6 +242,8 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
'snoozeCountdownTemplate': _plantillaMinutos(l10n.snoozeCountdown),
'openFolderTitle': l10n.openFolderChooserTitle,
'openRecordingTitle': l10n.openRecordingChooserTitle,
'missedTitle': l10n.alarmMissedNotificationTitle,
'missedTemplate': _plantillaNombre(l10n.alarmMissedNotificationText),
});
} catch (e) {
debugPrint('[PluriWave][alarmas] setNotificationStrings ERROR $e');
@@ -209,6 +258,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
return traducir(sentinel).replaceFirst(sentinel.toString(), '{minutes}');
}
/// Same sentinel-swap approach as [_plantillaMinutos], but for a
/// `{String} -> String` message: swaps a unique sentinel token back for the
/// literal `{name}` placeholder Kotlin fills in at fire time.
static String _plantillaNombre(String Function(Object) traducir) {
const sentinel = 'PLURIWAVE_NAME_SENTINEL';
return traducir(sentinel).replaceFirst(sentinel, '{name}');
}
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@@ -286,10 +343,43 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
Future<void> ocultarNotificacionAlarma(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
@override
Future<void> ocultarSoloNotificacion(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotificationOnly', {'id': alarmaId});
@override
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
@override
Future<String?> alarmaSonandoId() async {
try {
return await _channel.invokeMethod<String>('getActiveRingingAlarmId');
} catch (e) {
// Fail-toward-silence (Finding 2): a query failure must NOT be
// mistaken for "nothing is ringing" by callers like
// EstadoAlarmas._detenerSiEstaSonando, which would otherwise skip the
// stop entirely on a genuinely ringing alarm. Rethrow so the caller can
// fall back to the id-scoped legacy stop instead.
debugPrint('[PluriWave][alarmas] alarmaSonandoId ERROR $e');
rethrow;
}
}
@override
Future<ResultadoDetencion> detenerSonidoActivo() async {
try {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'stopActiveAlarm',
);
if (raw == null) return ResultadoDetencion.fallo;
return ResultadoDetencion.fromMap(raw);
} catch (e) {
debugPrint('[PluriWave][alarmas] detenerSonidoActivo ERROR $e');
return ResultadoDetencion.fallo;
}
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(