fix(alarmas): stop scheduling failures from being silent
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s

Reported: on a Redmi C55 the alarm never rang, no full-screen window, no
pre-notice -- "as if there were no alarm at all". Same build works on a
Poco X7 Pro.

Not device-specific. EstadoAlarmas already recorded per-alarm scheduling
failures and exposed ultimaExcepcionPara, but no screen ever called it,
so a failed alarm rendered identically to a working one.

- Scheduling failures now mark their own card
- The three native paths that only logged -- an unarmed pre-notice, a
  refused foreground-service start, a per-alarm reschedule failing after
  boot -- report to Dart and become per-alarm exceptions
- After a save, the native pending-alarm count is cross-checked, so an
  alarm that never reached the OS is caught immediately

Additive throughout: successful scheduling behaves identically and no
logic branches on manufacturer.

Tests: 1029 -> 1051.
This commit is contained in:
2026-07-31 23:27:58 +02:00
43 changed files with 1612 additions and 3 deletions
@@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) {
)
)
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
NativeSchedulingFailures.clear(
appContext,
spec.id,
NativeSchedulingFailures.TYPE_PRE_NOTICE
)
} catch (_: SecurityException) {
// Silent before this fix: the main alarm can still arm via
// setAlarmClock (exempt from the exact-alarm permission), so
// the alarm itself rings while its 30-minute reminder simply
// never appears, with nothing surfaced anywhere but logcat.
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
NativeSchedulingFailures.record(
appContext,
spec.id,
NativeSchedulingFailures.TYPE_PRE_NOTICE
)
}
} else if (spec.triggerAtMillis > now) {
appContext.sendBroadcast(
@@ -846,8 +860,22 @@ class AlarmScheduler(private val context: Context) {
// the native recompute inside scheduleSpec.
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
Log.d(tag, "alarm.reschedule OK id=$id")
NativeSchedulingFailures.clear(
appContext,
id,
NativeSchedulingFailures.TYPE_RESCHEDULE
)
} catch (error: Throwable) {
// Silent before this fix: one alarm's reschedule failure used
// to just log and move to the next id, leaving that ONE
// alarm unscheduled after a reboot/unlock/app-update with no
// signal anywhere but logcat.
Log.e(tag, "alarm.reschedule failed id=$id", error)
NativeSchedulingFailures.record(
appContext,
id,
NativeSchedulingFailures.TYPE_RESCHEDULE
)
}
}
}
@@ -855,6 +883,18 @@ class AlarmScheduler(private val context: Context) {
fun pendingAlarmCount(): Int =
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
/**
* Scheduling-reliability failures the native side recorded on its own
* (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground-
* service start, and post-boot/unlock reschedule failures never go
* through a Dart method-channel call that could throw, so they are
* persisted here instead and synced by Flutter on the next app launch
* -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own
* cold-start-sync shape.
*/
fun scheduleFailures(): List<Map<String, Any>> =
NativeSchedulingFailures.all(appContext)
fun handledOccurrences(): List<Map<String, Any>> =
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
.mapNotNull { id ->
@@ -1265,3 +1305,91 @@ class AlarmScheduler(private val context: Context) {
private fun JSONObject.optNullableLong(name: String): Long? =
if (has(name) && !isNull(name)) optLong(name) else null
/**
* Persisted store for scheduling-reliability failures the native side
* catches and previously only logged (fix/alarmas-fallos-silenciosos, item
* 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice),
* a refused foreground-service start (PluriWaveAlarmService.start), and a
* per-alarm reschedule failure after boot/unlock
* (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object
* (not nested in [AlarmScheduler]'s own instance state) so
* [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler]
* instance of its own -- can record a failure too, using the exact same
* [Context]-scoped, device-protected-storage `SharedPreferences` file
* [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by
* hand since Kotlin constants cannot be shared across files without a third
* file).
*
* Only the LATEST failure per alarm is kept (mirrors the Dart-side
* `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability
* signal, not an audit log.
*/
object NativeSchedulingFailures {
private const val PREFS = "pluriwave_alarm_scheduler"
private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids"
private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_"
private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */
const val TYPE_PRE_NOTICE = "preNoticeFailed"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */
const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */
const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed"
private fun prefs(context: Context) =
context.applicationContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun record(context: Context, id: String, type: String) {
if (id.isBlank()) return
val store = prefs(context)
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(id)
store.edit()
.putStringSet(KEY_FAILURE_IDS, ids)
.putString("$KEY_FAILURE_TYPE_PREFIX$id", type)
.putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis())
.apply()
Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type")
}
/**
* Clears the failure recorded for [id] ONLY when its current type is
* [type] -- type-scoped on purpose (mirrors the Dart-side
* `limpiarFalloProgramacion`), so a foreground-service success never
* erases an unrelated, still-outstanding pre-notice failure for the
* same alarm.
*/
fun clear(context: Context, id: String, type: String) {
val store = prefs(context)
val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
if (storedType != type) return
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
if (!ids.remove(id)) return
store.edit()
.putStringSet(KEY_FAILURE_IDS, ids)
.remove("$KEY_FAILURE_TYPE_PREFIX$id")
.remove("$KEY_FAILURE_AT_PREFIX$id")
.apply()
}
fun all(context: Context): List<Map<String, Any>> {
val store = prefs(context)
return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id ->
val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
?: return@mapNotNull null
val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L)
.takeIf { it > 0L }
?: return@mapNotNull null
mapOf(
"alarmId" to id,
"type" to type,
"atMillis" to at
)
}
}
}
@@ -250,6 +250,10 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel getNativeSnoozeState")
result.success(alarmScheduler.nativeSnoozeStates())
}
"getNativeSchedulingFailures" -> {
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
result.success(alarmScheduler.scheduleFailures())
}
"setNotificationStrings" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
@@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() {
startForeground(NOTIFICATION_ID, notification)
}
} catch (error: Throwable) {
// Silent before this fix: same user-visible symptom as a refused
// startForegroundService (the ring never actually starts) --
// recorded under the SAME tipo so the alarms list surfaces it
// regardless of which of the two calls the OS refused.
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
NativeSchedulingFailures.record(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
releaseWakeLock()
// Second documented clear site (feedback item, READ-5): this
// branch never reaches stopEverything(), so without the same
@@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() {
stopSelf()
return
}
NativeSchedulingFailures.clear(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
startAudio(
alarmId,
stationName,
@@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() {
fun start(context: Context, source: Intent) {
ensureChannel(context)
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtras(source)
@@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() {
try {
ContextCompat.startForegroundService(context, intent)
Log.d(TAG, "alarm.service start requested")
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.clear(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
} catch (error: Throwable) {
// Silent before this fix: a fire-and-forget call from the
// receiver's ACTION_FIRE branch -- if the OS refuses the
// foreground-service start (background-restricted app), the
// ring never happens and nothing surfaced it anywhere but
// logcat, "as if there were no alarm at all".
Log.e(TAG, "alarm.service start failed", error)
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.record(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
}
}
+192 -1
View File
@@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier {
);
await _sincronizarTodas();
await cargarDiagnostico();
await cargarFallosNativos();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
@@ -117,8 +118,11 @@ class EstadoAlarmas extends ChangeNotifier {
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
);
await android.programar(guardada);
await _limpiarFalloProgramacion(guardada.id);
await _verificarRegistroNativo(guardada.id);
} catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
@@ -198,6 +202,89 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
/// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos-
/// silenciosos): before this, a failed `android.programar` call only set
/// the transient, alarm-agnostic [_error] string — the alarms list had no
/// way to mark the SPECIFIC card affected, so a failed alarm rendered
/// exactly like a working one. Never rethrows: a failure recording its own
/// failure must not mask the ORIGINAL scheduling error already captured in
/// [_error].
Future<void> _registrarFalloProgramacion(
String alarmaId, {
String tipo = ExcepcionAlarma.tipoFalloProgramacion,
}) async {
try {
final alarma = _buscarAlarma(alarmaId);
final ejecucion = alarma?.proximaProgramable ?? servicio.ahora();
final config = await servicio.registrarFalloProgramacion(
alarmaId,
ejecucion,
tipo,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e');
}
}
/// Clears a previously recorded scheduling failure once a later attempt
/// for the same alarm succeeds (D5-style recovery, mirroring how [_error]
/// itself already clears on a successful retry). Type-scoped: a
/// successful `android.programar` call only proves the MAIN alarm
/// registration (and, transitively, that any stale post-boot reschedule
/// failure no longer applies) -- it says nothing about the pre-notice or
/// foreground-service subsystems, so those are left untouched here.
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
try {
var config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloProgramacion,
);
_aplicar(config);
config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloReprogramacionArranque,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
}
}
/// Verifies the OS genuinely registered [alarmaId] after a successful
/// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a
/// scheduling call that returns without throwing is not proof enough by
/// itself -- this cross-check against the native pending-alarm count is
/// exactly what would have caught the reported "alarm never rings, no
/// exception anywhere" case. Compares a FRESH native count against how
/// many alarms Dart believes are currently active-with-a-next-run; a
/// native count that falls short is recorded as a failure for the alarm
/// the user just interacted with. Never overrides an already-caught
/// programar() exception (this only runs on ITS success path).
Future<void> _verificarRegistroNativo(String alarmaId) async {
try {
final alarma = _buscarAlarma(alarmaId);
if (alarma == null ||
!alarma.activa ||
alarma.proximaProgramable == null) {
return;
}
final diag = await android.diagnostico();
_diagnostico = diag;
final esperadas =
_alarmas
.where((a) => a.activa && a.proximaProgramable != null)
.length;
if (diag.alarmasNativasPendientes < esperadas) {
_error =
'Alarma guardada, pero el sistema no confirma que quedó registrada.';
await _registrarFalloProgramacion(alarmaId);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e');
}
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
@@ -267,10 +354,12 @@ class EstadoAlarmas extends ChangeNotifier {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
@@ -298,10 +387,12 @@ class EstadoAlarmas extends ChangeNotifier {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
@@ -447,6 +538,36 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
/// Drains the failures the NATIVE side recorded on its own and turns each
/// into a per-alarm exception, so the card can mark it.
///
/// These three paths used to log to logcat and stop there: a pre-notice
/// that could not be armed, a refused foreground-service start when the
/// alarm should have rung, and a per-alarm reschedule that failed after a
/// reboot. None of them run inside a Dart call, so nothing on this side
/// ever learned they happened — an alarm could sit switched on in the
/// list having never reached the OS. Reading them at startup is what
/// makes the reported "as if there were no alarm" visible.
///
/// Deliberately tolerant: a failed read is logged and swallowed, never
/// surfaced as an alarm error, because a diagnostics gap must not look
/// like a scheduling problem.
Future<void> cargarFallosNativos() async {
try {
final fallos = await android.fallosNativosProgramacion();
for (final fallo in fallos) {
await _registrarFalloProgramacion(fallo.alarmaId, tipo: fallo.tipo);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e');
}
}
/// Records a snooze the native layer performed by itself (Decision 2.1).
/// The native scheduler already re-registered setAlarmClock, so this only
/// persists the canonical state — it MUST NOT call android.programar again.
@@ -546,6 +667,60 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
}
await _importarSnoozesNativosActivos();
await _importarFallosProgramacionNativos();
}
/// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports
/// scheduling-reliability failures the NATIVE side recorded on its own --
/// a pre-notice `SecurityException`, a refused foreground-service start,
/// or a per-alarm reschedule failure after boot/unlock -- none of which
/// ever go through a Dart method-channel call that could throw. Without
/// this sync, these three failures stayed invisible forever (only
/// logcat), even after this app-launch fix reads them.
Future<void> _importarFallosProgramacionNativos() async {
try {
final fallos = await android.obtenerFallosProgramacionNativos();
final reportadoPorAlarma = {
for (final fallo in fallos) fallo.alarmaId: fallo,
};
// Reconcile stale copies: the native side clears its OWN record the
// next time that specific subsystem succeeds (pre-notice/foreground-
// service), so an alarm previously imported with one of those tipos
// that is no longer reported here means it already recovered --
// without this, the card would keep showing a problem that fixed
// itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque`
// are NOT reconciled here -- those already clear on the Dart side's
// own successful `android.programar` calls.
for (final alarma in _alarmas) {
final actual = ultimaExcepcionPara(alarma.id);
final esTipoReconciliable =
actual != null &&
(actual.tipo == ExcepcionAlarma.tipoFalloPreaviso ||
actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido);
if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) {
final config = await servicio.limpiarFalloProgramacion(
alarma.id,
actual.tipo,
);
_aplicar(config);
}
}
for (final fallo in fallos) {
final config = await servicio.registrarFalloProgramacion(
fallo.alarmaId,
fallo.ocurridoEn,
fallo.tipo,
);
_aplicar(config);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos importados count=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e');
}
}
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
@@ -622,8 +797,24 @@ class EstadoAlarmas extends ChangeNotifier {
if (_alarmas.any((alarma) => alarma.activa)) {
await _solicitarPermisosNecesariosParaAlarma();
}
// Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a
// SINGLE alarm's `programar` throw aborted the whole loop, so every
// sibling AFTER the failing one in `_alarmas` silently never reached
// `android.programar` on this pass -- on a fresh launch (`inicializar`)
// that meant some active alarms were never (re)armed with the OS at all,
// with nothing to show for it beyond a generic load error. Each alarm
// now gets its own outcome recorded, and one failure never blocks its
// siblings.
for (final alarma in _alarmas) {
await android.programar(alarma);
try {
await android.programar(alarma);
await _limpiarFalloProgramacion(alarma.id);
} catch (e) {
debugPrint(
'[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e',
);
await _registrarFalloProgramacion(alarma.id);
}
}
}
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة",
"alarmCardSchedulingFailedMessage": "لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.",
"alarmCardPreNoticeFailedMessage": "تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.",
"alarmDiagnosticsExactAlarmsTitle": "جدولة المنبه بدقة",
"alarmDiagnosticsExactAlarmsHint": "يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.",
"alarmDiagnosticsNotificationsTitle": "الإشعارات",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত",
"alarmCardSchedulingFailedMessage": "এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।",
"alarmCardPreNoticeFailedMessage": "এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।",
"alarmDiagnosticsExactAlarmsTitle": "নির্ভুল অ্যালার্ম শিডিউলিং",
"alarmDiagnosticsExactAlarmsHint": "ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।",
"alarmDiagnosticsNotificationsTitle": "বিজ্ঞপ্তি",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert",
"alarmCardSchedulingFailedMessage": "Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.",
"alarmCardPreNoticeFailedMessage": "Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.",
"alarmDiagnosticsExactAlarmsTitle": "Genaue Alarmplanung",
"alarmDiagnosticsExactAlarmsHint": "Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.",
"alarmDiagnosticsNotificationsTitle": "Benachrichtigungen",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "Paused for vacation",
"alarmCardSchedulingFailedMessage": "This alarm could not be registered with the system, so it may not ring.",
"alarmCardPreNoticeFailedMessage": "This alarm is scheduled, but its early reminder could not be set.",
"alarmDiagnosticsExactAlarmsTitle": "Exact alarm scheduling",
"alarmDiagnosticsExactAlarmsHint": "Lets the alarm ring at the exact minute you set, even while the phone is asleep.",
"alarmDiagnosticsNotificationsTitle": "Notifications",
+2
View File
@@ -801,6 +801,8 @@
}
},
"alarmCardVacationPausedBadge": "Pausada por vacaciones",
"alarmCardSchedulingFailedMessage": "Esta alarma no se pudo registrar en el sistema, así que podría no sonar.",
"alarmCardPreNoticeFailedMessage": "Esta alarma está programada, pero no se pudo activar su aviso previo.",
"alarmDiagnosticsExactAlarmsTitle": "Programación de alarma exacta",
"alarmDiagnosticsExactAlarmsHint": "Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.",
"alarmDiagnosticsNotificationsTitle": "Notificaciones",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "En pause pour les vacances",
"alarmCardSchedulingFailedMessage": "Cette alarme n'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.",
"alarmCardPreNoticeFailedMessage": "Cette alarme est programmée, mais son rappel anticipé n'a pas pu être activé.",
"alarmDiagnosticsExactAlarmsTitle": "Programmation d'alarme précise",
"alarmDiagnosticsExactAlarmsHint": "Permet à l'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.",
"alarmDiagnosticsNotificationsTitle": "Notifications",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया",
"alarmCardSchedulingFailedMessage": "यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।",
"alarmCardPreNoticeFailedMessage": "यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।",
"alarmDiagnosticsExactAlarmsTitle": "सटीक अलार्म शेड्यूलिंग",
"alarmDiagnosticsExactAlarmsHint": "फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।",
"alarmDiagnosticsNotificationsTitle": "सूचनाएं",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "Dijeda karena liburan",
"alarmCardSchedulingFailedMessage": "Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.",
"alarmCardPreNoticeFailedMessage": "Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.",
"alarmDiagnosticsExactAlarmsTitle": "Penjadwalan alarm presisi",
"alarmDiagnosticsExactAlarmsHint": "Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.",
"alarmDiagnosticsNotificationsTitle": "Notifikasi",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "In pausa per le vacanze",
"alarmCardSchedulingFailedMessage": "Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.",
"alarmCardPreNoticeFailedMessage": "Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.",
"alarmDiagnosticsExactAlarmsTitle": "Programmazione sveglia esatta",
"alarmDiagnosticsExactAlarmsHint": "Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.",
"alarmDiagnosticsNotificationsTitle": "Notifiche",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "休暇のため一時停止中",
"alarmCardSchedulingFailedMessage": "このアラームはシステムに登録できなかったため、鳴らない可能性があります。",
"alarmCardPreNoticeFailedMessage": "このアラームは設定されていますが、事前通知を設定できませんでした。",
"alarmDiagnosticsExactAlarmsTitle": "正確なアラームのスケジュール設定",
"alarmDiagnosticsExactAlarmsHint": "スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。",
"alarmDiagnosticsNotificationsTitle": "通知",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "Pausada por férias",
"alarmCardSchedulingFailedMessage": "Este alarme não pôde ser registrado no sistema, por isso pode não tocar.",
"alarmCardPreNoticeFailedMessage": "Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.",
"alarmDiagnosticsExactAlarmsTitle": "Agendamento exato do alarme",
"alarmDiagnosticsExactAlarmsHint": "Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.",
"alarmDiagnosticsNotificationsTitle": "Notificações",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска",
"alarmCardSchedulingFailedMessage": "Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.",
"alarmCardPreNoticeFailedMessage": "Этот будильник запланирован, но не удалось настроить предварительное напоминание.",
"alarmDiagnosticsExactAlarmsTitle": "Точное планирование будильника",
"alarmDiagnosticsExactAlarmsHint": "Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.",
"alarmDiagnosticsNotificationsTitle": "Уведомления",
+2
View File
@@ -842,6 +842,8 @@
}
},
"alarmCardVacationPausedBadge": "因假期已暂停",
"alarmCardSchedulingFailedMessage": "该闹钟未能在系统中注册,因此可能不会响铃。",
"alarmCardPreNoticeFailedMessage": "该闹钟已设置,但其提前提醒未能设置成功。",
"alarmDiagnosticsExactAlarmsTitle": "精确闹钟排程",
"alarmDiagnosticsExactAlarmsHint": "即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。",
"alarmDiagnosticsNotificationsTitle": "通知",
+12
View File
@@ -3074,6 +3074,18 @@ abstract class AppLocalizations {
/// **'Pausada por vacaciones'**
String get alarmCardVacationPausedBadge;
/// No description provided for @alarmCardSchedulingFailedMessage.
///
/// In es, this message translates to:
/// **'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'**
String get alarmCardSchedulingFailedMessage;
/// No description provided for @alarmCardPreNoticeFailedMessage.
///
/// In es, this message translates to:
/// **'Esta alarma está programada, pero no se pudo activar su aviso previo.'**
String get alarmCardPreNoticeFailedMessage;
/// No description provided for @alarmDiagnosticsExactAlarmsTitle.
///
/// In es, this message translates to:
+8
View File
@@ -1691,6 +1691,14 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
@override
String get alarmCardSchedulingFailedMessage =>
'لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.';
@override
String get alarmCardPreNoticeFailedMessage =>
'تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'جدولة المنبه بدقة';
+8
View File
@@ -1700,6 +1700,14 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
@override
String get alarmCardSchedulingFailedMessage =>
'এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।';
@override
String get alarmCardPreNoticeFailedMessage =>
'এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'নির্ভুল অ্যালার্ম শিডিউলিং';
+8
View File
@@ -1713,6 +1713,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
@override
String get alarmCardSchedulingFailedMessage =>
'Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Genaue Alarmplanung';
+8
View File
@@ -1693,6 +1693,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'Paused for vacation';
@override
String get alarmCardSchedulingFailedMessage =>
'This alarm could not be registered with the system, so it may not ring.';
@override
String get alarmCardPreNoticeFailedMessage =>
'This alarm is scheduled, but its early reminder could not be set.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Exact alarm scheduling';
+8
View File
@@ -1707,6 +1707,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
@override
String get alarmCardSchedulingFailedMessage =>
'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Esta alarma está programada, pero no se pudo activar su aviso previo.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programación de alarma exacta';
+8
View File
@@ -1716,6 +1716,14 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
@override
String get alarmCardSchedulingFailedMessage =>
'Cette alarme n\'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Cette alarme est programmée, mais son rappel anticipé n\'a pas pu être activé.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programmation d\'alarme précise';
+8
View File
@@ -1695,6 +1695,14 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
@override
String get alarmCardSchedulingFailedMessage =>
'यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।';
@override
String get alarmCardPreNoticeFailedMessage =>
'यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'सटीक अलार्म शेड्यूलिंग';
+8
View File
@@ -1703,6 +1703,14 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
@override
String get alarmCardSchedulingFailedMessage =>
'Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Penjadwalan alarm presisi';
+8
View File
@@ -1715,6 +1715,14 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
@override
String get alarmCardSchedulingFailedMessage =>
'Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programmazione sveglia esatta';
+8
View File
@@ -1646,6 +1646,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
@override
String get alarmCardSchedulingFailedMessage =>
'このアラームはシステムに登録できなかったため、鳴らない可能性があります。';
@override
String get alarmCardPreNoticeFailedMessage =>
'このアラームは設定されていますが、事前通知を設定できませんでした。';
@override
String get alarmDiagnosticsExactAlarmsTitle => '正確なアラームのスケジュール設定';
+8
View File
@@ -1703,6 +1703,14 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'Pausada por férias';
@override
String get alarmCardSchedulingFailedMessage =>
'Este alarme não pôde ser registrado no sistema, por isso pode não tocar.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Agendamento exato do alarme';
+8
View File
@@ -1707,6 +1707,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
@override
String get alarmCardSchedulingFailedMessage =>
'Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Этот будильник запланирован, но не удалось настроить предварительное напоминание.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Точное планирование будильника';
+6
View File
@@ -1638,6 +1638,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get alarmCardVacationPausedBadge => '因假期已暂停';
@override
String get alarmCardSchedulingFailedMessage => '该闹钟未能在系统中注册,因此可能不会响铃。';
@override
String get alarmCardPreNoticeFailedMessage => '该闹钟已设置,但其提前提醒未能设置成功。';
@override
String get alarmDiagnosticsExactAlarmsTitle => '精确闹钟排程';
+37
View File
@@ -302,6 +302,43 @@ class ExcepcionAlarma {
final DateTime ejecucion;
final String tipo;
/// User-requested skip of the next occurrence (the only [tipo] this model
/// originally supported). `ServicioProgramacionAlarmas._esValida` only
/// treats THIS tipo as an actual schedule skip -- every tipo below records
/// a scheduling-reliability failure and must never affect which occurrence
/// fires next.
static const tipoSaltoSiguiente = 'skipNext';
/// The main alarm registration with the OS failed (`android.programar`
/// threw). Recorded per-alarm so the alarms list can mark the exact card
/// affected instead of only a transient, alarm-agnostic app-wide message.
static const tipoFalloProgramacion = 'schedulingFailed';
/// The main alarm registered successfully but its 30-minute pre-notice
/// reminder did not (native `SecurityException` scheduling the pre-notice
/// alone) -- distinguished from [tipoFalloProgramacion] because the alarm
/// itself will still ring; only the early warning is missing.
static const tipoFalloPreaviso = 'preNoticeFailed';
/// The OS refused to start the foreground ringing service when the alarm
/// fired (e.g. a background-restricted app), so the alarm never actually
/// rang even though it was armed.
static const tipoFalloServicioSonido = 'foregroundServiceFailed';
/// A per-alarm reschedule after boot/unlock failed while sibling alarms
/// succeeded, leaving this one specific alarm unscheduled.
static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed';
/// Every tipo above that represents a reliability FAILURE rather than a
/// deliberate user action -- used by the UI to decide whether to mark a
/// card, and by [ServicioAlarmas] to know which prior record to replace.
static const tiposFallo = {
tipoFalloProgramacion,
tipoFalloPreaviso,
tipoFalloServicioSonido,
tipoFalloReprogramacionArranque,
};
Map<String, dynamic> toJson() => {
'alarmaId': alarmaId,
'ejecucion': ejecucion.toIso8601String(),
+98
View File
@@ -286,6 +286,20 @@ class _TarjetaAlarma extends StatelessWidget {
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
];
// fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was
// never read from any screen, so a failed native scheduling attempt (main
// alarm, pre-notice, foreground service, or a post-boot reschedule)
// rendered exactly like a healthy alarm -- switched on, no visible sign
// anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente`
// as a real skip, so any OTHER tipo found here is a reliability failure,
// never a deliberate user action.
final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id);
final fallo =
ultimaExcepcion != null &&
ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo)
? ultimaExcepcion
: null;
return Dismissible(
key: ValueKey('tarjeta-alarma-${alarma.id}'),
direction: DismissDirection.horizontal,
@@ -417,6 +431,14 @@ class _TarjetaAlarma extends StatelessWidget {
),
),
],
if (fallo != null) ...[
const SizedBox(height: 6),
_AvisoFalloProgramacion(
alarmaId: alarma.id,
esSoloPreaviso:
fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso,
),
],
],
),
),
@@ -470,6 +492,82 @@ class _TarjetaAlarma extends StatelessWidget {
}
}
/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos):
/// renders INSIDE the card's own content, in its own small tap target --
/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe =
/// delete) are untouched; this inner `InkWell` only claims its own region
/// and pushes the diagnostics screen instead of opening the editor.
class _AvisoFalloProgramacion extends StatelessWidget {
const _AvisoFalloProgramacion({
required this.alarmaId,
required this.esSoloPreaviso,
});
final String alarmaId;
/// True when only the pre-notice reminder failed (the alarm itself is
/// still scheduled) -- the user's reported symptom explicitly called out
/// a missing pre-notice as distinct from the alarm never ringing at all,
/// so the message must not conflate the two.
final bool esSoloPreaviso;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final color = Theme.of(context).colorScheme.error;
return Material(
type: MaterialType.transparency,
child: InkWell(
key: ValueKey('tarjeta-alarma-fallo-$alarmaId'),
borderRadius: BorderRadius.circular(8),
onTap: () => _abrirDiagnostico(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
esSoloPreaviso
? l10n.alarmCardPreNoticeFailedMessage
: l10n.alarmCardSchedulingFailedMessage,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(height: 2),
Text(
l10n.androidReliabilityReview,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: color,
decoration: TextDecoration.underline,
),
),
],
),
),
],
),
),
),
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
/// works regardless of locale text direction.
class _FondoSwipeEliminarAlarma extends StatelessWidget {
+67
View File
@@ -350,6 +350,73 @@ class ServicioAlarmas {
return nuevo;
});
/// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas-
/// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model
/// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara`
/// surfaces it on the exact alarm card affected instead of only a
/// transient, alarm-agnostic message. A previous FAILURE record for the
/// same alarm is replaced (only the latest attempt's outcome matters) --
/// any `skipNext` exception for this or other alarms is left untouched.
/// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only
/// treats `tipoSaltoSiguiente` as an actual skip.
Future<ConfiguracionAlarmas> registrarFalloProgramacion(
String alarmaId,
DateTime ejecucion,
String tipo,
) => _enCola(() async {
final config = await _configActual();
final excepciones = [
..._sinFalloPrevio(config.excepciones, alarmaId),
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo),
];
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
/// Clears the outstanding failure record for [alarmaId] ONLY when its
/// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind
/// succeeded). Type-scoped on purpose: a successful main-alarm schedule
/// call proves nothing about the pre-notice or foreground-service
/// subsystems, so it must never clear a failure recorded for those. No-op
/// when there is nothing to clear or the recorded tipo does not match.
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
String alarmaId,
String tipo,
) => _enCola(() async {
final config = await _configActual();
final actual = config.excepciones.where((e) => e.alarmaId == alarmaId);
final tieneEseTipo = actual.any((e) => e.tipo == tipo);
if (!tieneEseTipo) return config;
final excepciones =
config.excepciones
.where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo))
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
List<ExcepcionAlarma> _sinFalloPrevio(
List<ExcepcionAlarma> excepciones,
String alarmaId,
) =>
excepciones
.where(
(e) =>
!(e.alarmaId == alarmaId &&
ExcepcionAlarma.tiposFallo.contains(e.tipo)),
)
.toList();
Future<ConfiguracionAlarmas> posponerEjecucion(
String alarmaId,
DateTime ejecucion,
@@ -161,6 +161,35 @@ class EjecucionAlarmaNativa {
}
}
/// A scheduling-reliability failure the NATIVE side recorded on its own
/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the
/// ringing foreground service, and a post-boot/unlock reschedule can each
/// fail without ever going through a Dart method-channel call that could
/// throw -- the native scheduler persists these instead (mirroring how
/// handled occurrences and snooze state already survive a killed engine),
/// and this is the cold-start sync so the Dart side finds out at all.
class FalloProgramacionNativo {
const FalloProgramacionNativo({
required this.alarmaId,
required this.tipo,
required this.ocurridoEn,
});
final String alarmaId;
final String tipo;
final DateTime ocurridoEn;
factory FalloProgramacionNativo.fromMap(Map<Object?, Object?> map) {
return FalloProgramacionNativo(
alarmaId: map['alarmId'] as String? ?? '',
tipo: map['type'] as String? ?? '',
ocurridoEn: DateTime.fromMillisecondsSinceEpoch(
(map['atMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
abstract class PuertoAlarmasAndroid {
Stream<EventoAlarmaAndroid> get eventosAlarma;
@@ -170,6 +199,24 @@ abstract class PuertoAlarmasAndroid {
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
/// Failures the NATIVE side recorded on its own, outside any Dart call:
/// a pre-notice that could not be armed, a refused foreground-service
/// start when the alarm should have rung, and a per-alarm reschedule that
/// failed after a reboot. Each entry carries the alarm id and one of
/// [ExcepcionAlarma]'s `tipoFallo*` constants.
///
/// Before this existed every one of those paths logged to logcat and
/// stopped there, so an alarm could sit switched on in the list having
/// never reached the OS at all — the user's "as if there were no alarm".
///
/// Returns the typed model rather than raw maps ON PURPOSE:
/// [FalloProgramacionNativo.fromMap] is the single place the native key
/// names (`alarmId`/`type`/`atMillis`) appear. Consuming raw maps here
/// once silently dropped every entry, because the caller guessed Spanish
/// key names and the fake was seeded with the same guess — the test
/// confirmed the mistake instead of catching it.
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion();
Future<void> ocultarNotificacionAlarma(String alarmaId);
/// Notification-only dismissal (RES-1): hides the fire notification for
@@ -203,6 +250,11 @@ abstract class PuertoAlarmasAndroid {
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
/// Scheduling-reliability failures the native side recorded on its own
/// (pre-notice, foreground-service start, or post-boot reschedule) since
/// the last sync.
Future<List<FalloProgramacionNativo>> obtenerFallosProgramacionNativos();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
@@ -393,6 +445,26 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
}
}
@override
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
try {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(FalloProgramacionNativo.fromMap)
.where((f) => f.alarmaId.isNotEmpty && f.tipo.isNotEmpty)
.toList();
} catch (e) {
// Never let a diagnostics read break alarm handling: an older build
// of the native side simply has no such channel method.
debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e');
return const [];
}
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
@@ -494,6 +566,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
.toList();
}
@override
Future<List<FalloProgramacionNativo>>
obtenerFallosProgramacionNativos() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(FalloProgramacionNativo.fromMap)
.where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);
@@ -150,9 +150,15 @@ class ServicioProgramacionAlarmas {
if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) {
return false;
}
// Only a deliberate user skip ever removes a candidate occurrence.
// Reliability-failure records share this same list/model (so the alarms
// list can surface them per-alarm via `ultimaExcepcionPara`), but they
// must never be mistaken for a skip — that would silently jump the
// alarm to its NEXT occurrence instead of just flagging the failed one.
return !excepciones.any(
(excepcion) =>
excepcion.alarmaId == alarma.id &&
excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente &&
_mismaEjecucion(excepcion.ejecucion, candidato),
);
}
@@ -0,0 +1,112 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// The native scheduler records three failures entirely on its own, outside
/// any Dart call: a pre-notice that could not be armed, a refused
/// foreground-service start when the alarm should have rung, and a per-alarm
/// reschedule that failed after a reboot.
///
/// Before this wiring existed, all three logged to logcat and stopped there.
/// The alarm stayed switched on in the list, drawn exactly as if scheduling
/// had succeeded, and simply never fired — the reported "as if there were no
/// alarm at all". These tests pin the drain path that makes them visible.
///
/// They deliberately build fixtures through [FalloProgramacionNativo.fromMap]
/// using the REAL native key names (`alarmId`/`type`/`atMillis`, see
/// `AlarmScheduler.kt:1389-1391`). An earlier draft seeded the fake with
/// guessed Spanish keys and consumed the same guess, so every entry would
/// have been silently dropped in production while the tests stayed green.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakePuertoAlarmasAndroid android;
setUp(() {
SharedPreferences.setMockInitialValues({});
android = FakePuertoAlarmasAndroid();
});
EstadoAlarmas crearEstado() =>
EstadoAlarmas(android: android, iniciarAutomaticamente: false);
/// Mirrors exactly what the native side puts on the channel.
FalloProgramacionNativo falloNativo(String alarmaId, String tipo) =>
FalloProgramacionNativo.fromMap({
'alarmId': alarmaId,
'type': tipo,
'atMillis': 1700000000000,
});
test('the fixture helper decodes the real native key names', () {
final fallo = falloNativo('a0', ExcepcionAlarma.tipoFalloPreaviso);
expect(fallo.alarmaId, 'a0');
expect(fallo.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test('a native pre-notice failure becomes a per-alarm exception', () async {
android.fallosNativos = [
falloNativo('a1', ExcepcionAlarma.tipoFalloPreaviso),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
final fallo = estado.ultimaExcepcionPara('a1');
expect(fallo, isNotNull);
expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test(
'a refused foreground-service start becomes a per-alarm exception',
() async {
android.fallosNativos = [
falloNativo('a2', ExcepcionAlarma.tipoFalloServicioSonido),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(
estado.ultimaExcepcionPara('a2')?.tipo,
ExcepcionAlarma.tipoFalloServicioSonido,
);
},
);
test('only the alarm whose reschedule failed is marked', () async {
android.fallosNativos = [
falloNativo('a3', ExcepcionAlarma.tipoFalloReprogramacionArranque),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(estado.ultimaExcepcionPara('a3'), isNotNull);
expect(
estado.ultimaExcepcionPara('a4'),
isNull,
reason: 'a sibling alarm that scheduled fine must stay unmarked',
);
});
test('a failing native read never surfaces as an alarm error', () async {
// A diagnostics gap must not look like a scheduling problem: an older
// native build simply has no such channel method.
android.fallaLecturaFallosNativos = true;
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(estado.error, isNull);
});
}
+139
View File
@@ -611,6 +611,145 @@ void main() {
},
);
test(
'guardarAlarma: cuando android.programar falla, marca la alarma con una '
'excepcion de fallo visible via ultimaExcepcionPara',
() async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'fallo1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final excepcion = estado.ultimaExcepcionPara('fallo1');
expect(excepcion, isNotNull);
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
expect(estado.error, isNotNull);
},
);
test(
'guardarAlarma: un reintento exitoso limpia la excepcion de fallo previa',
() async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
final alarma = const AlarmaMusical(
id: 'fallo2',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
);
await estado.guardarAlarma(alarma);
expect(estado.ultimaExcepcionPara('fallo2'), isNotNull);
android.fallaProgramar = false;
await estado.guardarAlarma(estado.alarmas.single);
expect(estado.ultimaExcepcionPara('fallo2'), isNull);
},
);
test(
'guardarAlarma en el camino feliz nunca registra una excepcion de fallo',
() async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'ok1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(estado.ultimaExcepcionPara('ok1'), isNull);
expect(estado.error, isNull);
},
);
test(
'inicializar: un fallo de programacion en UNA alarma no aborta la '
'sincronizacion de las demas (S-sincronizarTodas continua tras error)',
() async {
final android = FakePuertoAlarmasAndroid()
..idsFallanProgramar.add('rota');
final servicio = ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 6, 0),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'rota',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'sana',
nombre: 'Sana',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.inicializar();
expect(
android.programadas.map((a) => a.id),
contains('sana'),
reason:
'la alarma sana debe seguir programandose aunque la rota falle',
);
expect(estado.ultimaExcepcionPara('rota'), isNotNull);
expect(estado.ultimaExcepcionPara('sana'), isNull);
},
);
group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () {
test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye '
'"ahora" (dias restantes derivables de finDia), o null si ninguno '
@@ -0,0 +1,139 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// fix/alarmas-fallos-silenciosos, item 3: "verify the alarm is actually
/// registered, and say so if it is not". `android.programar` returning
/// without throwing is not proof enough by itself -- this is the check that
/// would have caught the reported case immediately (the native side can
/// silently fail to persist the registration even when the channel call
/// itself reports success).
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('guardarAlarma detecta que el conteo nativo no refleja la alarma '
'guardada, aunque android.programar no haya lanzado', () async {
// Fixed at 0 regardless of what programar() does internally --
// simulates the native side accepting the channel call but never
// actually persisting the registration.
final android = FakePuertoAlarmasAndroid()..alarmasNativasPendientes = 0;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'silenciosa1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final excepcion = estado.ultimaExcepcionPara('silenciosa1');
expect(excepcion, isNotNull);
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
expect(estado.error, isNotNull);
});
test('guardarAlarma en el camino feliz (conteo nativo coincide) no registra '
'fallo alguno', () async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'sana1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(estado.ultimaExcepcionPara('sana1'), isNull);
expect(estado.error, isNull);
});
test(
'inicializar importa los fallos nativos (pre-aviso, servicio en primer '
'plano, reprogramacion tras arranque) como excepciones por alarma',
() async {
final android =
FakePuertoAlarmasAndroid()
..fallosProgramacionNativos.addAll([
FalloProgramacionNativo(
alarmaId: 'con-preaviso-roto',
tipo: ExcepcionAlarma.tipoFalloPreaviso,
ocurridoEn: DateTime(2026, 5, 25, 7),
),
FalloProgramacionNativo(
alarmaId: 'servicio-rechazado',
tipo: ExcepcionAlarma.tipoFalloServicioSonido,
ocurridoEn: DateTime(2026, 5, 25, 7),
),
]);
final servicio = ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 7, 30),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'con-preaviso-roto',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'servicio-rechazado',
nombre: 'Diaria',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.inicializar();
final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto');
expect(falloPreaviso, isNotNull);
expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado');
expect(falloServicio, isNotNull);
expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido);
},
);
}
+56 -2
View File
@@ -14,6 +14,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
final soloOcultadas = <String>[];
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
final snoozesNativos = <EstadoSnoozeNativo>[];
final fallosProgramacionNativos = <FalloProgramacionNativo>[];
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
bool ignoraOptimizacionBateria = true;
int solicitudesExencionBateria = 0;
@@ -26,10 +27,27 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
bool puedeProgramarExactas = true;
bool notificacionesPermitidas = true;
bool puedeUsarPantallaCompleta = true;
int alarmasNativasPendientes = 0;
String fabricante = 'test';
int versionSdk = 35;
/// Ids [programar] most recently scheduled as active-with-a-next-run (kept
/// in sync with [cancelar] too), mirroring the real native scheduler's own
/// pending-alarm registry (fix/alarmas-fallos-silenciosos, item 3: "verify
/// the alarm is actually registered"). Backs [alarmasNativasPendientes]'s
/// DEFAULT so a test that never touches that field gets a value that
/// tracks reality instead of a frozen `0` -- a test that explicitly
/// assigns the field (many `pantalla_diagnostico_alarmas_test.dart` cases
/// do, to model a stale/corrupt native count on purpose) keeps getting
/// EXACTLY that value regardless of what programar/cancelar do afterward.
final _idsRegistradosNativamente = <String>{};
int? _alarmasNativasPendientesFijado;
int get alarmasNativasPendientes =>
_alarmasNativasPendientesFijado ?? _idsRegistradosNativamente.length;
set alarmasNativasPendientes(int valor) =>
_alarmasNativasPendientesFijado = valor;
/// Test-only failure switch (diagnostics screen, "intent not resolving"
/// coverage): when true, every `abrir*`/`solicitar*` system-screen action
/// below reports failure (as a real device does when a ROM lacks that
@@ -41,6 +59,12 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
/// could not otherwise produce.
bool fallaProgramar = false;
/// Test-only PER-ALARM failure switch (fix/alarmas-fallos-silenciosos):
/// [programar] throws only for ids in this set, letting a test simulate
/// one alarm failing to schedule while its siblings succeed -- the global
/// [fallaProgramar] switch cannot express that (it fails everything).
final Set<String> idsFallanProgramar = {};
/// Test-only failure switch: when true, [detenerSonidoActivo] reports an
/// unconfirmed/failed stop instead of a confirmed one.
bool fallaDetener = false;
@@ -73,15 +97,21 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Future<void> programar(AlarmaMusical alarma) async {
if (fallaProgramar) {
if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) {
throw StateError('fake programar failure');
}
programadas.add(alarma);
if (alarma.activa && alarma.proximaProgramable != null) {
_idsRegistradosNativamente.add(alarma.id);
} else {
_idsRegistradosNativamente.remove(alarma.id);
}
}
@override
Future<void> cancelar(String alarmaId) async {
canceladas.add(alarmaId);
_idsRegistradosNativamente.remove(alarmaId);
}
@override
@@ -157,9 +187,33 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async =>
List.of(snoozesNativos);
@override
Future<List<FalloProgramacionNativo>>
obtenerFallosProgramacionNativos() async =>
List.of(fallosProgramacionNativos);
int solicitudesPermisoAlarmasExactas = 0;
int solicitudesPermisoPantallaCompleta = 0;
/// Native-recorded failures the next read should return. Tests seed this
/// to simulate a pre-notice that never armed, a refused foreground-service
/// start, or a per-alarm reschedule that failed after a reboot.
List<FalloProgramacionNativo> fallosNativos = const [];
int lecturasFallosNativos = 0;
/// Simulates an older native build with no such channel method.
bool fallaLecturaFallosNativos = false;
@override
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
lecturasFallosNativos++;
if (fallaLecturaFallosNativos) {
throw StateError('canal no disponible');
}
return fallosNativos;
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
solicitudesPermisoAlarmasExactas++;
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Card-level visibility for a failed scheduling attempt (fix/alarmas-
/// fallos-silenciosos): before this, `ultimaExcepcionPara` existed but was
/// never read from any screen, so a failed alarm rendered exactly like a
/// working one -- switched on, no visible sign anything was wrong.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> montarPantalla(
WidgetTester tester,
EstadoAlarmas estadoAlarmas,
) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
testWidgets(
'una alarma cuya programacion fallo muestra un aviso en su tarjeta',
(tester) async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
expect(
find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')),
findsOneWidget,
);
},
);
testWidgets(
'una alarma programada correctamente NO muestra el aviso de fallo',
(tester) async {
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'ok1',
nombre: 'Sana',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
expect(
find.byKey(const ValueKey('tarjeta-alarma-fallo-ok1')),
findsNothing,
);
},
);
testWidgets('el aviso de fallo abre la pantalla de diagnostico al tocarlo', (
tester,
) async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(PantallaDiagnosticoAlarmas), findsOneWidget);
});
}
@@ -0,0 +1,184 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Coverage for the scheduling-reliability failure records (fix/alarmas-
/// fallos-silenciosos): a failed `android.programar` call is no longer only
/// a transient, alarm-agnostic `EstadoAlarmas.error` string -- it is also
/// recorded per-alarm through the SAME `ExcepcionAlarma` model `saltarProxima`
/// already uses, so `EstadoAlarmas.ultimaExcepcionPara` can surface it on the
/// exact card affected.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('registrarFalloProgramacion agrega una excepcion de fallo para la '
'alarma indicada', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(config.excepciones.single.alarmaId, 'a1');
expect(
config.excepciones.single.tipo,
ExcepcionAlarma.tipoFalloProgramacion,
);
});
test('registrarFalloProgramacion reemplaza un fallo previo de la MISMA '
'alarma en vez de acumular', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloPreaviso,
);
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 26, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(
config.excepciones.single.tipo,
ExcepcionAlarma.tipoFalloProgramacion,
);
});
test('registrarFalloProgramacion NUNCA toca las excepciones skipNext de '
'otras alarmas ni de la misma', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.saltarProxima('a1');
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 26, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(2));
expect(
config.excepciones.map((e) => e.tipo),
containsAll(<String>[
ExcepcionAlarma.tipoSaltoSiguiente,
ExcepcionAlarma.tipoFalloProgramacion,
]),
);
});
test('limpiarFalloProgramacion elimina el fallo registrado para esa '
'alarma', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, isEmpty);
});
test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto '
'(cada subsistema se limpia por su cuenta)', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloPreaviso,
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste '
'cambios', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, isEmpty);
});
}
@@ -75,6 +75,42 @@ void main() {
expect(proxima, DateTime(2026, 5, 23, 9));
});
test(
'un registro de fallo de programacion NO omite esa ejecucion (solo '
'skipNext debe hacerlo)',
() {
final alarma = AlarmaMusical(
id: 'a4',
nombre: 'Diaria',
hora: 9,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
final ocurrencia = DateTime(2026, 5, 22, 9);
final proxima = servicio.calcularProxima(
alarma: alarma,
desde: DateTime(2026, 5, 22, 8),
excepciones: [
ExcepcionAlarma(
alarmaId: 'a4',
ejecucion: ocurrencia,
tipo: ExcepcionAlarma.tipoFalloProgramacion,
),
],
);
expect(
proxima,
ocurrencia,
reason:
'un fallo de programacion registrado no debe comportarse '
'como un salto de usuario',
);
},
);
test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () {
expect(
servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 10),