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:
+13
-8
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "يرن باستخدام الصوت الداخلي الآمن.",
|
||||
"ringingPreparingInternalAudio": "جارٍ تجهيز الصوت الداخلي الآمن.",
|
||||
"stopAlarmAction": "إيقاف المنبه",
|
||||
"alarmStopFailedMessage": "تعذّر التأكد من إيقاف المنبه. حاول مرة أخرى.",
|
||||
"alarmForceStopAction": "إيقاف قسري",
|
||||
"alarmMissedNotificationTitle": "منبه فائت",
|
||||
"alarmMissedNotificationText": "تم كتم {name} تلقائيًا بعد 10 دقائق.",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "إيقاف مؤقت",
|
||||
"miniPlayerOpenLabel": "فتح المشغل لـ {stationName}",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "নিরাপদ অভ্যন্তরীণ শব্দ দিয়ে বাজছে।",
|
||||
"ringingPreparingInternalAudio": "নিরাপদ অভ্যন্তরীণ শব্দ প্রস্তুত হচ্ছে।",
|
||||
"stopAlarmAction": "অ্যালার্ম বন্ধ করুন",
|
||||
"alarmStopFailedMessage": "অ্যালার্ম বন্ধ হয়েছে তা নিশ্চিত করা যায়নি। আবার চেষ্টা করুন।",
|
||||
"alarmForceStopAction": "জোর করে বন্ধ করুন",
|
||||
"alarmMissedNotificationTitle": "মিস হওয়া অ্যালার্ম",
|
||||
"alarmMissedNotificationText": "১০ মিনিট পর {name} স্বয়ংক্রিয়ভাবে নিঃশব্দ করা হয়েছে।",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "বিরতি দিন",
|
||||
"miniPlayerOpenLabel": "{stationName}-এর প্লেয়ার খুলুন",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {}}},
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "Son sécurisé interne en cours.",
|
||||
"ringingPreparingInternalAudio": "Préparation du son sécurisé interne.",
|
||||
"stopAlarmAction": "Arrêter l’alarme",
|
||||
"alarmStopFailedMessage": "Nous n’avons pas pu confirmer l’arrêt de l’alarme. Réessayez.",
|
||||
"alarmForceStopAction": "Forcer l’arrê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": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "सुरक्षित आंतरिक ध्वनि के साथ बज रहा है।",
|
||||
"ringingPreparingInternalAudio": "सुरक्षित आंतरिक ध्वनि तैयार हो रही है।",
|
||||
"stopAlarmAction": "अलार्म रोकें",
|
||||
"alarmStopFailedMessage": "हम पुष्टि नहीं कर सके कि अलार्म बंद हुआ। फिर से कोशिश करें।",
|
||||
"alarmForceStopAction": "जबरन बंद करें",
|
||||
"alarmMissedNotificationTitle": "छूटा हुआ अलार्म",
|
||||
"alarmMissedNotificationText": "10 मिनट बाद {name} अपने आप म्यूट कर दिया गया।",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "विराम दें",
|
||||
"miniPlayerOpenLabel": "{stationName} का प्लेयर खोलें",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 l’arresto 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": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "安全な内部音で鳴っています。",
|
||||
"ringingPreparingInternalAudio": "安全な内部音を準備中です。",
|
||||
"stopAlarmAction": "アラームを停止",
|
||||
"alarmStopFailedMessage": "アラームが停止したことを確認できませんでした。もう一度お試しください。",
|
||||
"alarmForceStopAction": "強制停止",
|
||||
"alarmMissedNotificationTitle": "アラームの聞き逃し",
|
||||
"alarmMissedNotificationText": "{name}は10分後に自動的に消音されました。",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "一時停止",
|
||||
"miniPlayerOpenLabel": "{stationName}のプレーヤーを開く",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "Звонит с безопасным внутренним звуком.",
|
||||
"ringingPreparingInternalAudio": "Подготовка безопасного внутреннего звука.",
|
||||
"stopAlarmAction": "Остановить будильник",
|
||||
"alarmStopFailedMessage": "Не удалось подтвердить, что будильник остановлен. Попробуйте снова.",
|
||||
"alarmForceStopAction": "Принудительно остановить",
|
||||
"alarmMissedNotificationTitle": "Пропущенный будильник",
|
||||
"alarmMissedNotificationText": "{name} был автоматически отключён через 10 минут.",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "Пауза",
|
||||
"miniPlayerOpenLabel": "Открыть плеер для {stationName}",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "正在使用内部安全音频响铃。",
|
||||
"ringingPreparingInternalAudio": "正在准备内部安全音频。",
|
||||
"stopAlarmAction": "停止闹钟",
|
||||
"alarmStopFailedMessage": "无法确认闹钟已停止,请重试。",
|
||||
"alarmForceStopAction": "强制停止",
|
||||
"alarmMissedNotificationTitle": "错过的闹钟",
|
||||
"alarmMissedNotificationText": "{name}已在10分钟后自动静音。",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "暂停",
|
||||
"miniPlayerOpenLabel": "打开 {stationName} 的播放器",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 => 'إيقاف مؤقت';
|
||||
|
||||
|
||||
@@ -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 => 'বিরতি দিন';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -913,6 +913,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get stopAlarmAction => 'Arrêter l’alarme';
|
||||
|
||||
@override
|
||||
String get alarmStopFailedMessage =>
|
||||
'Nous n’avons pas pu confirmer l’arrêt de l’alarme. Réessayez.';
|
||||
|
||||
@override
|
||||
String get alarmForceStopAction => 'Forcer l’arrê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';
|
||||
|
||||
|
||||
@@ -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 => 'विराम दें';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -910,6 +910,21 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get stopAlarmAction => 'Ferma sveglia';
|
||||
|
||||
@override
|
||||
String get alarmStopFailedMessage =>
|
||||
'Non siamo riusciti a confermare l’arresto 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';
|
||||
|
||||
|
||||
@@ -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 => '一時停止';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Пауза';
|
||||
|
||||
|
||||
@@ -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 => '暂停';
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user