Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes 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). Defaults to
|
|
// "premium" (ungated) so every pre-existing test/call site that never
|
|
// wires entitlement keeps its exact previous behavior — production
|
|
// wiring in `app.dart` always passes the real callback.
|
|
bool Function()? esPremium,
|
|
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
|
|
android = android ?? ServicioAlarmasAndroid(),
|
|
_prefs = prefs,
|
|
_esPremium = esPremium ?? (() => true) {
|
|
// 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();
|
|
}
|
|
}
|