EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to `() => true`, so any construction site that forgot to wire entitlement compiled fine and silently ran ungated — failing OPEN to premium and disabling the paywall with no test able to catch it. The parameter is now required with no default. Production wiring in app.dart was already correct and is unchanged; the 184 pre-existing test call sites now pass `() => true` explicitly, which is exactly the old implicit default, so every assertion is untouched. EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it inherits the same contract. The one test that existed to pin the old default is renamed to describe what it still covers (the premium path through iniciar() with no duracion); its assertions are unchanged.
1037 lines
41 KiB
Dart
1037 lines
41 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/alarma_musical.dart';
|
|
import '../servicios/servicio_alarmas.dart';
|
|
import '../servicios/servicio_alarmas_android.dart';
|
|
import '../servicios/servicio_programacion_alarmas.dart';
|
|
|
|
/// Distinct "limit reached" signal (Design ADR-5, freemium-gating spec
|
|
/// "Alarm Count Cap At 5"): kept SEPARATE from [EstadoAlarmas.error], which
|
|
/// stays reserved for native scheduling failures — overloading it would
|
|
/// surface a free-tier limit as a scheduling failure in `app.dart`'s global
|
|
/// snackbar path.
|
|
enum ResultadoGuardarAlarma { guardada, limiteAlcanzado }
|
|
|
|
class EstadoAlarmas extends ChangeNotifier {
|
|
EstadoAlarmas({
|
|
ServicioAlarmas? servicio,
|
|
PuertoAlarmasAndroid? android,
|
|
SharedPreferences? prefs,
|
|
bool iniciarAutomaticamente = true,
|
|
// iap-freemium-unlock (Design ADR-3): entitlement query, mirroring
|
|
// `EstadoGrabacion`'s `emisoraActual` callback-injection shape rather
|
|
// than a direct `EstadoEntitlement` dependency (this notifier must stay
|
|
// constructible with zero widget-tree/Provider context). REQUIRED on
|
|
// purpose: an optional parameter with any default lets a forgotten
|
|
// wiring compile and silently pick a tier, and no test can catch that.
|
|
// Callers must state the entitlement source explicitly.
|
|
required bool Function() esPremium,
|
|
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
|
|
android = android ?? ServicioAlarmasAndroid(),
|
|
_prefs = prefs,
|
|
_esPremium = esPremium {
|
|
// Decision 2.1 (snooze sync): the native layer reports its own snoozes
|
|
// back through alarmFired/snoozed; record them here so the Flutter
|
|
// config stays the single source of truth.
|
|
_eventosNativosSub = this.android.eventosAlarma.listen(
|
|
_alRecibirEventoNativo,
|
|
);
|
|
if (iniciarAutomaticamente) {
|
|
inicializar();
|
|
}
|
|
}
|
|
|
|
final ServicioAlarmas servicio;
|
|
final PuertoAlarmasAndroid android;
|
|
final SharedPreferences? _prefs;
|
|
final bool Function() _esPremium;
|
|
static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada';
|
|
|
|
/// Free-tier alarm cap (freemium-gating spec "Alarm Count Cap At 5").
|
|
static const maxAlarmasFree = 5;
|
|
|
|
List<AlarmaMusical> _alarmas = [];
|
|
List<RangoVacaciones> _vacaciones = [];
|
|
List<ExcepcionAlarma> _excepciones = [];
|
|
DiagnosticoAlarmasAndroid? _diagnostico;
|
|
Timer? _refresco;
|
|
Timer? _vigilancia;
|
|
StreamSubscription<EventoAlarmaAndroid>? _eventosNativosSub;
|
|
final _alarmasVencidasController =
|
|
StreamController<AlarmaMusical>.broadcast();
|
|
final Set<String> _ejecucionesEmitidas = {};
|
|
static const _margenDisparoLocal = Duration(seconds: 45);
|
|
|
|
// Bounds for _ejecucionesEmitidas (S3-R6): entries older than the
|
|
// retention window are pruned; the set never exceeds the cap.
|
|
static const _retencionEjecucionesEmitidas = Duration(hours: 24);
|
|
@visibleForTesting
|
|
static const maxEjecucionesEmitidas = 200;
|
|
bool _cargando = false;
|
|
String? _error;
|
|
|
|
/// Last alarm id recorded as MISSED (RES-1): lets the ringing screen
|
|
/// detect an external end-of-ring for its own alarm and reconcile.
|
|
String? ultimaAlarmaPerdidaId;
|
|
|
|
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
|
|
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
|
|
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
|
|
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
|
|
bool get cargando => _cargando;
|
|
String? get error => _error;
|
|
Stream<AlarmaMusical> get alarmasVencidasStream =>
|
|
_alarmasVencidasController.stream;
|
|
|
|
AlarmaMusical? get proximaAlarma {
|
|
final candidatas =
|
|
_alarmas.where((a) => a.activa && a.proximaProgramable != null).toList()
|
|
..sort(
|
|
(a, b) => a.proximaProgramable!.compareTo(b.proximaProgramable!),
|
|
);
|
|
return candidatas.isEmpty ? null : candidatas.first;
|
|
}
|
|
|
|
Future<void> inicializar() async {
|
|
debugPrint('[PluriWave][alarmas] inicializar');
|
|
_cargando = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
try {
|
|
await _sincronizarEjecucionesGestionadasPorAndroid();
|
|
final config = await servicio.recalcularTodas();
|
|
_aplicar(config);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}',
|
|
);
|
|
await _sincronizarTodas();
|
|
await cargarDiagnostico();
|
|
await cargarFallosNativos();
|
|
_activarRefresco();
|
|
} catch (e) {
|
|
_error = 'No se pudieron cargar las alarmas: $e';
|
|
debugPrint('[PluriWave][alarmas] inicializar ERROR $e');
|
|
} finally {
|
|
_cargando = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Pure query (freemium-gating spec "Alarm Count Cap At 5"): whether a NEW
|
|
/// alarm may be created right now. Counts ALL alarms regardless of
|
|
/// `activa` (Spec "6th alarm creation is blocked" — "any enabled state").
|
|
/// Always `true` for premium (no cap). Editing an existing id is never
|
|
/// subject to this — see [guardarAlarma]'s own new-vs-edit check.
|
|
bool puedeCrearAlarma() => _esPremium() || _alarmas.length < maxAlarmasFree;
|
|
|
|
Future<ResultadoGuardarAlarma> guardarAlarma(AlarmaMusical alarma) async {
|
|
// Gate BEFORE any native scheduling attempt (freemium-gating spec "6th
|
|
// alarm creation is blocked": "no native scheduling is attempted").
|
|
// Editing an alarm that already exists (by id) is NEVER capped — only
|
|
// genuinely NEW creation counts against the limit (Spec "Editing an
|
|
// existing alarm is unaffected", grandfathering).
|
|
final esAlarmaNueva = !_alarmas.any((a) => a.id == alarma.id);
|
|
if (esAlarmaNueva && !puedeCrearAlarma()) {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] guardar bloqueado por limite free id=${alarma.id}',
|
|
);
|
|
return ResultadoGuardarAlarma.limiteAlcanzado;
|
|
}
|
|
debugPrint(
|
|
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
|
|
);
|
|
// Mutation-while-ringing stop guard (SS-1a/SS-1b): fires BEFORE the save
|
|
// persists so an edit/toggle-off of the currently-ringing alarm always
|
|
// silences it first.
|
|
await _detenerSiEstaSonando(alarma.id);
|
|
final config = await servicio.guardarAlarma(alarma);
|
|
_aplicar(config);
|
|
try {
|
|
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id);
|
|
await _solicitarPermisosNecesariosParaAlarma();
|
|
debugPrint(
|
|
'[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();
|
|
return ResultadoGuardarAlarma.guardada;
|
|
}
|
|
|
|
Future<void> refrescarProgramacion() async {
|
|
debugPrint('[PluriWave][alarmas] refrescar programacion');
|
|
final config = await servicio.recalcularTodas();
|
|
_aplicar(config);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] proxima tras refrescar=${proximaAlarma?.id} ${proximaAlarma?.proximaEjecucion?.toIso8601String()}',
|
|
);
|
|
await _sincronizarTodas();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> cargarPersistidasSinRecalcular() async {
|
|
final config = await servicio.cargar();
|
|
_aplicar(config);
|
|
notifyListeners();
|
|
}
|
|
|
|
void marcarEjecucionGestionada(AlarmaMusical alarma) {
|
|
final proxima = alarma.proximaProgramable;
|
|
if (proxima == null) return;
|
|
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
|
|
_registrarEjecucionEmitida(key);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
int get ejecucionesEmitidasLength => _ejecucionesEmitidas.length;
|
|
|
|
/// Forwards the UI localizations to the native bridge so alarm and station
|
|
/// names sent to Android follow the app locale (Decision 3.2 — replaces
|
|
/// the old static `ServicioAlarmasAndroid.configurarLocalizaciones`).
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
android.configurarLocalizaciones(l10n);
|
|
}
|
|
|
|
Future<void> eliminarAlarma(String id) async {
|
|
debugPrint('[PluriWave][alarmas] eliminar id=$id');
|
|
final config = await servicio.eliminarAlarma(id);
|
|
_aplicar(config);
|
|
// Deleting the ringing alarm stops audio (SS-1c, regression lock): the
|
|
// centralized guard runs before cancelar, same as guardarAlarma.
|
|
await _detenerSiEstaSonando(id);
|
|
await android.cancelar(id);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Centralized mutation-while-ringing stop guard (Decision 5): every
|
|
/// mutation of the currently-ringing alarm routes through this ONE check
|
|
/// instead of per-call-site logic, so a mutation of a DIFFERENT (non-
|
|
/// ringing) alarm never touches the live ring (SS-1d).
|
|
Future<void> _detenerSiEstaSonando(String id) async {
|
|
try {
|
|
final sonando = await android.alarmaSonandoId();
|
|
if (sonando == id) {
|
|
await android.detenerSonidoActivo();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] detenerSiEstaSonando ERROR $e');
|
|
// Fail-toward-silence (Finding 2, eliminarAlarma regression): a failed
|
|
// query must not silently skip the stop when the alarm might genuinely
|
|
// be ringing. Fall back to the id-scoped legacy stop (the native side
|
|
// no-ops safely on a mismatch) inside its own try/catch so this outer
|
|
// flow (guardarAlarma/eliminarAlarma) always proceeds regardless.
|
|
try {
|
|
await android.detenerSonidoNativo(id);
|
|
} catch (fallbackError) {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] detenerSiEstaSonando fallback ERROR $fallbackError',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
|
|
Future<void> saltarProxima(String alarmaId) async {
|
|
debugPrint('[PluriWave][alarmas] saltar proxima id=$alarmaId');
|
|
final config = await servicio.saltarProxima(alarmaId);
|
|
_aplicar(config);
|
|
AlarmaMusical? alarma;
|
|
for (final item in _alarmas) {
|
|
if (item.id == alarmaId) {
|
|
alarma = item;
|
|
break;
|
|
}
|
|
}
|
|
if (alarma != null) {
|
|
await android.programar(alarma);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
|
|
);
|
|
final config = await servicio.guardarVacaciones(vacaciones);
|
|
_aplicar(config);
|
|
await _sincronizarTodas();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// The occurrence that is ACTUALLY ringing right now — the anchor both
|
|
/// ring-screen actions (Posponer and Detener) must close.
|
|
///
|
|
/// It is NEVER a future occurrence. When the native fire works, the
|
|
/// fire-time sync advances `proximaEjecucion` to the next one before the
|
|
/// user can even reach the ring screen, so taking `proximaEjecucion`
|
|
/// unguarded closes an occurrence that has not happened yet. For snooze
|
|
/// that showed up as "posponer 3" arming a full day out (observed
|
|
/// on-device: tomorrow 23:02). For stop it was worse and silent: the
|
|
/// future occurrence was recorded in `ultimaEjecucionGestionada`, which
|
|
/// `ServicioProgramacionAlarmas._esValida` then rejects for real — so a
|
|
/// Monday-only alarm stopped today simply never rang next Monday, and
|
|
/// every sibling alarm outranked it in the "next alarm" banner.
|
|
///
|
|
/// The candidates, newest first, each gated on "not meaningfully in the
|
|
/// future": [AlarmaMusical.snoozeOrigen] (a re-snooze keeps the original
|
|
/// anchor), then [AlarmaMusical.proximaEjecucion] (watchdog path: still
|
|
/// today's just-due occurrence), then
|
|
/// [AlarmaMusical.ultimaEjecucionGestionada] (native-fire path: the sync
|
|
/// recorded the ringing occurrence there), then now.
|
|
///
|
|
/// ONE helper for BOTH callers on purpose. This guard was written for
|
|
/// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten
|
|
/// lines below with the identical hazard and no guard, and it stayed that
|
|
/// way until a user lost a whole week of alarms. Do not re-inline it.
|
|
DateTime _ocurrenciaSonando(AlarmaMusical? alarma) =>
|
|
_ocurrenciaValida(alarma);
|
|
|
|
/// How far ahead the PRE-NOTICE notification's occurrence may legitimately
|
|
/// sit: it is armed exactly this far before the alarm, so between the
|
|
/// reminder appearing and the user tapping it, the occurrence has not
|
|
/// happened yet and rejecting it would be wrong.
|
|
///
|
|
/// Mirrors `AlarmScheduler.PRE_NOTICE_MILLIS` (30 min). Both sides must
|
|
/// agree or one of them starts discarding perfectly good anchors.
|
|
static const ventanaPreaviso = Duration(minutes: 30);
|
|
|
|
/// [_ocurrenciaSonando] generalized with a forward allowance, and with an
|
|
/// externally-supplied [propuesta] taking priority when it survives the
|
|
/// same check.
|
|
///
|
|
/// [propuesta] is what the NATIVE side reported as the occurrence its
|
|
/// notification was about. It is trusted first — it is better evidence than
|
|
/// anything reconstructed here — but only after being validated, because it
|
|
/// can arrive as a fallback the caller invented (`app.dart` substitutes
|
|
/// `alarma.proximaEjecucion` when the native event carries no occurrence,
|
|
/// and that field may already point at tomorrow).
|
|
DateTime _ocurrenciaValida(
|
|
AlarmaMusical? alarma, {
|
|
DateTime? propuesta,
|
|
Duration margen = Duration.zero,
|
|
}) {
|
|
final ahora = servicio.ahora();
|
|
final limite = ahora.add(
|
|
margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
|
);
|
|
DateTime? sonando(DateTime? candidata) =>
|
|
candidata != null && !candidata.isAfter(limite) ? candidata : null;
|
|
return sonando(propuesta) ??
|
|
sonando(alarma?.snoozeOrigen) ??
|
|
sonando(alarma?.proximaEjecucion) ??
|
|
sonando(alarma?.ultimaEjecucionGestionada) ??
|
|
ahora;
|
|
}
|
|
|
|
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
|
_error = null;
|
|
final ejecucion = _ocurrenciaSonando(alarma);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
|
|
);
|
|
await android.ocultarNotificacionAlarma(alarma.id);
|
|
final config = await servicio.posponerEjecucion(
|
|
alarma.id,
|
|
ejecucion,
|
|
minutos,
|
|
);
|
|
_aplicar(config);
|
|
final actualizada = _buscarAlarma(alarma.id);
|
|
try {
|
|
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();
|
|
}
|
|
|
|
/// "Posponer" on the PRE-NOTICE notification.
|
|
///
|
|
/// Reported on-device: this left the alarm snoozed for 1400+ minutes — a
|
|
/// whole day — instead of the configured few. The native lane got its guard
|
|
/// in 7054a4c, but Dart runs AFTERWARDS on this path (the receiver's
|
|
/// `postponeNext` fires, then `startActivity`, then this) and persists +
|
|
/// reschedules, so whatever it computes is the value that survives. It was
|
|
/// the last snooze path in the codebase with NO occurrence guard at all:
|
|
/// it took [ejecucion] on faith and turned it straight into the next alarm.
|
|
///
|
|
/// And [ejecucion] is not trustworthy: `app.dart` falls back to
|
|
/// `alarma.proximaEjecucion` whenever the native event carries no
|
|
/// occurrence, and that field can already point at tomorrow.
|
|
///
|
|
/// Validated through [_ocurrenciaValida] with a [ventanaPreaviso]
|
|
/// allowance — unlike the ringing-screen paths this occurrence legitimately
|
|
/// has NOT arrived yet, which is exactly why `_ocurrenciaSonando` could not
|
|
/// simply be reused here.
|
|
Future<void> posponerProximaDesdePreaviso(
|
|
AlarmaMusical alarma,
|
|
int minutos,
|
|
DateTime ejecucion,
|
|
) async {
|
|
_error = null;
|
|
final seguros = _snoozeSeguro(minutos);
|
|
final ocurrencia = _ocurrenciaValida(
|
|
alarma,
|
|
propuesta: ejecucion,
|
|
margen: ventanaPreaviso,
|
|
);
|
|
final snoozeHasta = ocurrencia.add(Duration(minutes: seguros));
|
|
debugPrint(
|
|
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros propuesta=${ejecucion.toIso8601String()} ocurrencia=${ocurrencia.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
|
|
);
|
|
await android.ocultarNotificacionAlarma(alarma.id);
|
|
final config = await servicio.posponerEjecucionHasta(
|
|
alarma.id,
|
|
// The VALIDATED occurrence, not the raw parameter: this becomes both
|
|
// `snoozeOrigen` and `ultimaEjecucionGestionada`, so passing the
|
|
// unchecked value here would poison the very state a9da855/0430059
|
|
// exist to keep clean.
|
|
ocurrencia,
|
|
snoozeHasta,
|
|
);
|
|
_aplicar(config);
|
|
final actualizada = _buscarAlarma(alarma.id);
|
|
try {
|
|
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();
|
|
}
|
|
|
|
Future<void> finalizarEjecucion(String alarmaId) async {
|
|
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
|
|
_error = null;
|
|
final alarma = _buscarAlarma(alarmaId);
|
|
// Same anchor as posponerAlarma, through the same helper: closing a
|
|
// future occurrence here marks it handled, and _esValida then skips it
|
|
// for real -- the alarm silently never rings that day. See
|
|
// [_ocurrenciaSonando].
|
|
final ejecucion = _ocurrenciaSonando(alarma);
|
|
await android.ocultarNotificacionAlarma(alarmaId);
|
|
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
|
|
// id-agnostic fail-safe stop directly (it always targets whatever is
|
|
// ringing). `detenido` reflects the VERIFIED native teardown state
|
|
// (activeRingingId cleared same-process after a synchronous stop), not a
|
|
// literal dispatch acknowledgement, so a genuine failure is never
|
|
// swallowed.
|
|
final resultado = await android.detenerSonidoActivo();
|
|
if (!resultado.detenido) {
|
|
_error = 'No se pudo confirmar que la alarma dejo de sonar.';
|
|
}
|
|
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
|
|
_aplicar(config);
|
|
await _sincronizarTodas();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Retryable force-stop affordance (SS-3b): re-invokes the same fail-safe
|
|
/// stop; success clears the recorded failure, another failure keeps it.
|
|
Future<void> forzarDetencion(String alarmaId) async {
|
|
debugPrint('[PluriWave][alarmas] forzar detencion id=$alarmaId');
|
|
final resultado = await android.detenerSonidoActivo();
|
|
_error =
|
|
resultado.detenido
|
|
? null
|
|
: 'No se pudo detener la alarma. Intentalo de nuevo.';
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Full premium gate (freemium-gating spec "Gated Feature Set (Exactly
|
|
/// 4)" — alarm vacations, unlike the alarm cap above, are gated entirely,
|
|
/// not counted): returns `false` without persisting anything when the
|
|
/// caller is free tier.
|
|
Future<bool> crearRangoVacaciones(RangoVacaciones rango) async {
|
|
if (!_esPremium()) {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] crear vacaciones bloqueado (free) id=${rango.id}',
|
|
);
|
|
return false;
|
|
}
|
|
final nuevos = [..._vacaciones, rango];
|
|
await guardarVacaciones(nuevos);
|
|
return true;
|
|
}
|
|
|
|
Future<void> eliminarRangoVacaciones(String id) async {
|
|
final nuevos = _vacaciones.where((v) => v.id != id).toList();
|
|
await guardarVacaciones(nuevos);
|
|
}
|
|
|
|
/// Issue 1 (feedback-pruebas): replaces the range with the same [id] in
|
|
/// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones`
|
|
/// were missing before this fix, leaving no way to fix a mistake in an
|
|
/// already-saved range (including the currently ACTIVE one, since a
|
|
/// freshly created range starts active immediately).
|
|
Future<void> editarRangoVacaciones(RangoVacaciones rango) async {
|
|
final nuevos = [
|
|
for (final actual in _vacaciones)
|
|
if (actual.id == rango.id) rango else actual,
|
|
];
|
|
await guardarVacaciones(nuevos);
|
|
}
|
|
|
|
// ── Vacation queries (design ADR-6, WU9) ──────────────────────────────
|
|
// Four PURE queries: none writes, none reschedules, none touches the
|
|
// native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method
|
|
// takes `{DateTime? ahora}` (clock injection) defaulting to
|
|
// `DateTime.now()` so tests can pass a fixed instant.
|
|
|
|
/// Currently-active vacation range, if today falls within one.
|
|
/// Delegates to the existing `RangoVacaciones.contiene(fecha)` — which
|
|
/// already handles the `activo` flag and day granularity — rather than
|
|
/// reimplementing date math (a second implementation is a second set of
|
|
/// off-by-one bugs). Callers derive "days remaining" themselves from the
|
|
/// returned range's `finDia`, the same way WU8's summary row already
|
|
/// does.
|
|
RangoVacaciones? rangoVacacionesActivo({DateTime? ahora}) {
|
|
final fecha = ahora ?? DateTime.now();
|
|
for (final rango in _vacaciones) {
|
|
if (rango.contiene(fecha)) return rango;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Ranges that have not yet started (`inicio > hoy`), soonest-first.
|
|
List<RangoVacaciones> vacacionesProximas({DateTime? ahora}) {
|
|
final fecha = ahora ?? DateTime.now();
|
|
final hoy = DateTime(fecha.year, fecha.month, fecha.day);
|
|
return _vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList()
|
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
|
}
|
|
|
|
/// Ranges whose end date has already passed (`fin < hoy`), most-recently-
|
|
/// ended first.
|
|
List<RangoVacaciones> vacacionesPasadas({DateTime? ahora}) {
|
|
final fecha = ahora ?? DateTime.now();
|
|
final hoy = DateTime(fecha.year, fecha.month, fecha.day);
|
|
return _vacaciones.where((rango) => rango.finDia.isBefore(hoy)).toList()
|
|
..sort((a, b) => b.finDia.compareTo(a.finDia));
|
|
}
|
|
|
|
/// Per-alarm pause impact for [rango]. Mirrors
|
|
/// `ServicioProgramacionAlarmas`'s own pause predicate EXACTLY
|
|
/// (`servicio_programacion_alarmas.dart`:
|
|
/// `!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)`)
|
|
/// — if these two ever diverge, the Vacaciones screen lies about which
|
|
/// alarms are paused. [rango] is accepted for API symmetry with the
|
|
/// other 3 queries above; the predicate itself needs no dates because it
|
|
/// only makes sense to call this for a range that IS currently active —
|
|
/// any alarm actually paused by it already has `sonarEnVacaciones ==
|
|
/// false`, which is exactly what the scheduler itself would have used to
|
|
/// skip that alarm's candidate occurrence.
|
|
ImpactoVacaciones impactoDeRango(RangoVacaciones rango) {
|
|
final pausadas = <AlarmaMusical>[];
|
|
final noAfectadas = <AlarmaMusical>[];
|
|
for (final alarma in _alarmas) {
|
|
if (!alarma.activa) continue;
|
|
if (alarma.sonarEnVacaciones) {
|
|
noAfectadas.add(alarma);
|
|
} else {
|
|
pausadas.add(alarma);
|
|
}
|
|
}
|
|
return ImpactoVacaciones(pausadas: pausadas, noAfectadas: noAfectadas);
|
|
}
|
|
|
|
ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) {
|
|
final candidatas =
|
|
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
|
|
..sort((a, b) => b.ejecucion.compareTo(a.ejecucion));
|
|
return candidatas.isEmpty ? null : candidatas.first;
|
|
}
|
|
|
|
Future<void> cargarDiagnostico() async {
|
|
try {
|
|
_diagnostico = await android.diagnostico();
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] diagnostico ERROR $e');
|
|
_diagnostico = null;
|
|
}
|
|
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.
|
|
Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async {
|
|
if (evento.accion == EventoAlarmaAndroid.accionSnoozeCancelled) {
|
|
await _registrarCancelacionSnoozeNativa(evento);
|
|
return;
|
|
}
|
|
if (evento.accion == EventoAlarmaAndroid.accionMissed) {
|
|
await _registrarEjecucionPerdida(evento);
|
|
return;
|
|
}
|
|
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
|
|
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
|
|
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
|
|
final origen =
|
|
evento.occurrenceAtMillis > 0
|
|
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
|
: hasta.subtract(Duration(minutes: evento.snoozeMinutes));
|
|
debugPrint(
|
|
'[PluriWave][alarmas] snooze nativo id=${evento.alarmaId} hasta=${hasta.toIso8601String()}',
|
|
);
|
|
try {
|
|
final config = await servicio.posponerEjecucionHasta(
|
|
evento.alarmaId,
|
|
origen,
|
|
hasta,
|
|
);
|
|
_aplicar(config);
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] snooze nativo ERROR $e');
|
|
}
|
|
}
|
|
|
|
/// Mirrors a native snooze cancellation ("Detener" on the countdown
|
|
/// notification). The native scheduler already advanced to the next normal
|
|
/// occurrence, so this only clears the snooze in the canonical config and
|
|
/// MUST NOT call android.programar again (would double-schedule).
|
|
Future<void> _registrarCancelacionSnoozeNativa(
|
|
EventoAlarmaAndroid evento,
|
|
) async {
|
|
if (evento.alarmaId.isEmpty) return;
|
|
final origen =
|
|
evento.occurrenceAtMillis > 0
|
|
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
|
: DateTime.now();
|
|
debugPrint(
|
|
'[PluriWave][alarmas] snooze cancelado nativo id=${evento.alarmaId} origen=${origen.toIso8601String()}',
|
|
);
|
|
try {
|
|
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
|
|
_aplicar(config);
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] cancelar snooze nativo ERROR $e');
|
|
}
|
|
}
|
|
|
|
/// Records a native auto-silence (MISSED) transition (Phase 6): the native
|
|
/// scheduler already rearmed the next occurrence (repeating) or left it
|
|
/// disabled (one-shot) at fire time, so this only marks the occurrence
|
|
/// handled -- it MUST NOT call android.programar again.
|
|
Future<void> _registrarEjecucionPerdida(EventoAlarmaAndroid evento) async {
|
|
if (evento.alarmaId.isEmpty) return;
|
|
final origen =
|
|
evento.occurrenceAtMillis > 0
|
|
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
|
: DateTime.now();
|
|
debugPrint(
|
|
'[PluriWave][alarmas] ejecucion perdida id=${evento.alarmaId} origen=${origen.toIso8601String()}',
|
|
);
|
|
try {
|
|
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
|
|
_aplicar(config);
|
|
ultimaAlarmaPerdidaId = evento.alarmaId;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] ejecucion perdida ERROR $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
|
|
try {
|
|
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
|
|
if (ejecuciones.isNotEmpty) {
|
|
final config = await servicio.sincronizarEjecucionesNativas({
|
|
for (final ejecucion in ejecuciones)
|
|
ejecucion.alarmaId: ejecucion.gestionadaEn,
|
|
});
|
|
_aplicar(config);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
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
|
|
/// performed while the Flutter engine was dead, before any recalculation
|
|
/// could erase them.
|
|
Future<void> _importarSnoozesNativosActivos() async {
|
|
try {
|
|
final snoozes = await android.obtenerEstadoSnoozeNativo();
|
|
if (snoozes.isEmpty) return;
|
|
final ahora = DateTime.now();
|
|
var config = await servicio.cargar();
|
|
var huboCambios = false;
|
|
for (final snooze in snoozes) {
|
|
if (!snooze.snoozeHasta.isAfter(ahora)) continue;
|
|
AlarmaMusical? alarma;
|
|
for (final candidata in config.alarmas) {
|
|
if (candidata.id == snooze.alarmaId) {
|
|
alarma = candidata;
|
|
break;
|
|
}
|
|
}
|
|
if (alarma == null || !alarma.activa) continue;
|
|
if (alarma.snoozeHasta == snooze.snoozeHasta) continue;
|
|
config = await servicio.posponerEjecucionHasta(
|
|
snooze.alarmaId,
|
|
snooze.snoozeOrigen,
|
|
snooze.snoozeHasta,
|
|
);
|
|
huboCambios = true;
|
|
}
|
|
if (huboCambios) {
|
|
_aplicar(config);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] snoozes nativos importados count=${snoozes.length}',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] importar snoozes nativos ERROR $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _solicitarPermisosNecesariosParaAlarma() async {
|
|
try {
|
|
final diag = await android.diagnostico();
|
|
_diagnostico = diag;
|
|
if (!diag.puedeProgramarExactas) {
|
|
await android.solicitarPermisoAlarmasExactas();
|
|
}
|
|
if (!diag.notificacionesPermitidas) {
|
|
await android.solicitarPermisoNotificaciones();
|
|
}
|
|
if (!diag.puedeUsarPantallaCompleta) {
|
|
await android.solicitarPermisoPantallaCompleta();
|
|
}
|
|
if (!diag.ignoraOptimizacionBateria) {
|
|
await _solicitarExencionBateriaUnaVez();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] permisos android ERROR $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _solicitarExencionBateriaUnaVez() async {
|
|
final prefs = _prefs ?? await SharedPreferences.getInstance();
|
|
if (prefs.getBool(_keyExencionBateriaSolicitada) ?? false) return;
|
|
await android.solicitarExencionBateria();
|
|
await prefs.setBool(_keyExencionBateriaSolicitada, true);
|
|
}
|
|
|
|
Future<void> _sincronizarTodas() async {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
|
|
);
|
|
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) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
AlarmaMusical? _buscarAlarma(String id) {
|
|
for (final alarma in _alarmas) {
|
|
if (alarma.id == id) return alarma;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
int _snoozeSeguro(int minutos) =>
|
|
minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
|
|
|
|
void _aplicar(ConfiguracionAlarmas config) {
|
|
_alarmas = config.alarmas;
|
|
_vacaciones = config.vacaciones;
|
|
_excepciones = config.excepciones;
|
|
}
|
|
|
|
void _activarRefresco() {
|
|
_refresco?.cancel();
|
|
_refresco = Timer.periodic(const Duration(minutes: 1), (_) {
|
|
refrescarProgramacion();
|
|
});
|
|
_vigilarAlarmasVencidas();
|
|
_vigilancia?.cancel();
|
|
_vigilancia = Timer.periodic(const Duration(seconds: 10), (_) {
|
|
_vigilarAlarmasVencidas();
|
|
});
|
|
}
|
|
|
|
void _vigilarAlarmasVencidas() {
|
|
final ahora = DateTime.now();
|
|
_depurarEjecucionesEmitidas(ahora);
|
|
for (final alarma in _alarmas) {
|
|
final proxima = alarma.proximaProgramable;
|
|
if (!alarma.activa || proxima == null) continue;
|
|
if (proxima.isAfter(ahora)) continue;
|
|
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
|
|
final retraso = ahora.difference(proxima);
|
|
if (retraso > _margenDisparoLocal) {
|
|
_registrarEjecucionEmitida(key);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
|
|
);
|
|
continue;
|
|
}
|
|
if (_registrarEjecucionEmitida(key)) {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
|
);
|
|
_alarmasVencidasController.add(alarma);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adds a `alarmId:millis` key and keeps the set bounded (S3-R6).
|
|
/// Returns whether the key was newly added (fire-dedup contract).
|
|
bool _registrarEjecucionEmitida(String key) {
|
|
final agregada = _ejecucionesEmitidas.add(key);
|
|
_depurarEjecucionesEmitidas(DateTime.now());
|
|
return agregada;
|
|
}
|
|
|
|
void _depurarEjecucionesEmitidas(DateTime ahora) {
|
|
final limite =
|
|
ahora.subtract(_retencionEjecucionesEmitidas).millisecondsSinceEpoch;
|
|
_ejecucionesEmitidas.removeWhere((key) => _millisDeEjecucion(key) < limite);
|
|
if (_ejecucionesEmitidas.length <= maxEjecucionesEmitidas) return;
|
|
// Still over the cap: evict the oldest occurrences first. Pruned keys
|
|
// cannot re-fire because occurrences beyond _margenDisparoLocal are
|
|
// ignored by _vigilarAlarmasVencidas anyway.
|
|
final ordenadas =
|
|
_ejecucionesEmitidas.toList()..sort(
|
|
(a, b) => _millisDeEjecucion(a).compareTo(_millisDeEjecucion(b)),
|
|
);
|
|
_ejecucionesEmitidas.removeAll(
|
|
ordenadas.take(_ejecucionesEmitidas.length - maxEjecucionesEmitidas),
|
|
);
|
|
}
|
|
|
|
int _millisDeEjecucion(String key) {
|
|
final separador = key.lastIndexOf(':');
|
|
if (separador < 0) return 0;
|
|
return int.tryParse(key.substring(separador + 1)) ?? 0;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_refresco?.cancel();
|
|
_vigilancia?.cancel();
|
|
_eventosNativosSub?.cancel();
|
|
_alarmasVencidasController.close();
|
|
super.dispose();
|
|
}
|
|
}
|