fix(alarmas): stop scheduling failures from being silent
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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -842,6 +842,8 @@
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة",
|
||||
"alarmCardSchedulingFailedMessage": "لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.",
|
||||
"alarmCardPreNoticeFailedMessage": "تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "جدولة المنبه بدقة",
|
||||
"alarmDiagnosticsExactAlarmsHint": "يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.",
|
||||
"alarmDiagnosticsNotificationsTitle": "الإشعارات",
|
||||
|
||||
@@ -842,6 +842,8 @@
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত",
|
||||
"alarmCardSchedulingFailedMessage": "এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।",
|
||||
"alarmCardPreNoticeFailedMessage": "এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "নির্ভুল অ্যালার্ম শিডিউলিং",
|
||||
"alarmDiagnosticsExactAlarmsHint": "ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।",
|
||||
"alarmDiagnosticsNotificationsTitle": "বিজ্ঞপ্তি",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -842,6 +842,8 @@
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया",
|
||||
"alarmCardSchedulingFailedMessage": "यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।",
|
||||
"alarmCardPreNoticeFailedMessage": "यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "सटीक अलार्म शेड्यूलिंग",
|
||||
"alarmDiagnosticsExactAlarmsHint": "फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।",
|
||||
"alarmDiagnosticsNotificationsTitle": "सूचनाएं",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -842,6 +842,8 @@
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "休暇のため一時停止中",
|
||||
"alarmCardSchedulingFailedMessage": "このアラームはシステムに登録できなかったため、鳴らない可能性があります。",
|
||||
"alarmCardPreNoticeFailedMessage": "このアラームは設定されていますが、事前通知を設定できませんでした。",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "正確なアラームのスケジュール設定",
|
||||
"alarmDiagnosticsExactAlarmsHint": "スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。",
|
||||
"alarmDiagnosticsNotificationsTitle": "通知",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -842,6 +842,8 @@
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска",
|
||||
"alarmCardSchedulingFailedMessage": "Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.",
|
||||
"alarmCardPreNoticeFailedMessage": "Этот будильник запланирован, но не удалось настроить предварительное напоминание.",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "Точное планирование будильника",
|
||||
"alarmDiagnosticsExactAlarmsHint": "Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.",
|
||||
"alarmDiagnosticsNotificationsTitle": "Уведомления",
|
||||
|
||||
@@ -842,6 +842,8 @@
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "因假期已暂停",
|
||||
"alarmCardSchedulingFailedMessage": "该闹钟未能在系统中注册,因此可能不会响铃。",
|
||||
"alarmCardPreNoticeFailedMessage": "该闹钟已设置,但其提前提醒未能设置成功。",
|
||||
"alarmDiagnosticsExactAlarmsTitle": "精确闹钟排程",
|
||||
"alarmDiagnosticsExactAlarmsHint": "即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。",
|
||||
"alarmDiagnosticsNotificationsTitle": "通知",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1691,6 +1691,14 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'جدولة المنبه بدقة';
|
||||
|
||||
|
||||
@@ -1700,6 +1700,14 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'নির্ভুল অ্যালার্ম শিডিউলিং';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1695,6 +1695,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => 'सटीक अलार्म शेड्यूलिंग';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1646,6 +1646,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'このアラームはシステムに登録できなかったため、鳴らない可能性があります。';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'このアラームは設定されていますが、事前通知を設定できませんでした。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => '正確なアラームのスケジュール設定';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1707,6 +1707,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage =>
|
||||
'Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage =>
|
||||
'Этот будильник запланирован, но не удалось настроить предварительное напоминание.';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle =>
|
||||
'Точное планирование будильника';
|
||||
|
||||
@@ -1638,6 +1638,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => '因假期已暂停';
|
||||
|
||||
@override
|
||||
String get alarmCardSchedulingFailedMessage => '该闹钟未能在系统中注册,因此可能不会响铃。';
|
||||
|
||||
@override
|
||||
String get alarmCardPreNoticeFailedMessage => '该闹钟已设置,但其提前提醒未能设置成功。';
|
||||
|
||||
@override
|
||||
String get alarmDiagnosticsExactAlarmsTitle => '精确闹钟排程';
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user