9 Commits
Author SHA1 Message Date
FreeTLab d945e1a313 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.
2026-07-31 23:27:58 +02:00
FreeTLab c507218462 merge: incorporate the CI version bump 2026-07-31 23:27:57 +02:00
FreeTLab f2528c930b fix(alarmas): decode native failures with the real channel key names
The first pass read 'alarmaId'/'tipo' from the channel payload while the
native side sends 'alarmId'/'type'/'atMillis' (AlarmScheduler.kt:1389).
Every entry would have been dropped silently in production.

The tests passed because the fake was seeded with the same guessed keys,
so they confirmed the mistake instead of catching it. Decoding now goes
through FalloProgramacionNativo.fromMap -- the single place native key
names appear -- and the fixtures build through that same constructor.
2026-07-31 23:27:45 +02:00
FreeTLab a8dca83cd9 feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and
PluriWaveAlarmService record a pre-notice that could not be armed, a
refused foreground-service start, and a per-alarm reschedule that failed
after a reboot -- but nothing read them, so all three still ended at
logcat.

EstadoAlarmas now drains them at startup and turns each into a per-alarm
exception, which the card already knows how to mark. An alarm that never
reached the OS stops looking identical to one that did.

The read is deliberately tolerant: a failure to read is logged and
swallowed, never surfaced as an alarm error, so a diagnostics gap cannot
masquerade as a scheduling problem.
2026-07-31 23:24:01 +02:00
FreeTLab 7722f204ca feat(alarmas): verify native registration after a successful save
android.programar() returning without throwing was treated as proof
the OS registered the alarm -- this is exactly the gap the reported
case fell through. guardarAlarma now cross-checks a fresh native
pending-alarm count against how many alarms Dart believes are
active-with-a-next-run right after a successful schedule call, and
records a failure for the just-saved alarm when the native count
falls short.

FakePuertoAlarmasAndroid.alarmasNativasPendientes now defaults to a
count derived from programar()/cancelar() calls (mirroring the real
native scheduler's own registry) instead of a frozen 0, while any
test that explicitly assigns the field keeps getting exactly that
value regardless of what programar/cancelar do afterward -- verified
against the full suite, no regressions.
2026-07-31 21:26:41 +02:00
FreeTLab c107c0e18a feat(alarmas): surface scheduling failures on the alarm card
Wires EstadoAlarmas.ultimaExcepcionPara into PantallaAlarmas: an
alarm with an outstanding scheduling-failure exception now shows a
calm warning line (distinguishing a pre-notice-only failure from the
alarm itself not being registered) with a tap target into the
reliability diagnostics screen. The warning is its own small tap
target nested inside the existing card InkWell, so tap-to-edit,
swipe-to-delete and the hero "Saltar" chip are untouched.

Adds alarmCardSchedulingFailedMessage/alarmCardPreNoticeFailedMessage
to all 13 ARB locales with real per-language translations (verified
against arb_parity_test and arb_anti_copy_test).
2026-07-31 21:19:57 +02:00
FreeTLab fd1b91fe9e fix(alarmas): wire scheduling failures into per-alarm exceptions
guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record
a scheduling failure via ServicioAlarmas.registrarFalloProgramacion
on catch and clear it on a successful (re)schedule, in addition to
the existing transient EstadoAlarmas.error string. This makes the
failure visible per-alarm via ultimaExcepcionPara instead of only a
generic app-wide message.

Also fixes _sincronizarTodas: a single alarm's android.programar
throw used to abort the whole loop, silently skipping every sibling
alarm scheduled AFTER it on that pass (including on every app launch,
via inicializar). Each alarm's outcome is now independent.
2026-07-31 21:08:21 +02:00
FreeTLab 47d0b8a053 feat(alarmas): record and clear per-alarm scheduling failures
Adds ServicioAlarmas.registrarFalloProgramacion/limpiarFalloProgramacion,
persisting a scheduling-reliability failure through the same
ExcepcionAlarma model saltarProxima already uses. Only one failure
record is kept per alarm (latest attempt wins) and skipNext entries
for any alarm are never touched. EstadoAlarmas wiring follows next.
2026-07-31 21:00:51 +02:00
FreeTLab 88bd251eba fix(alarmas): scope schedule-skip exceptions to skipNext only
ExcepcionAlarma._esValida matched ANY exception tipo against an
occurrence, treating it as a user skip. Only the 'skipNext' tipo
existed until now, but the next commits reuse the same model to
record scheduling-reliability failures per alarm (so the alarms list
can surface them via ultimaExcepcionPara) -- without this guard, a
recorded failure would be silently treated as if the user asked to
skip that occurrence, corrupting scheduling. Adds tipo constants to
ExcepcionAlarma for the upcoming failure kinds.
2026-07-31 20:59:02 +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),