Files
pluriwave/lib/estado/estado_alarmas.dart
T
FreeTLab 9dfcf0b428 feat(vacaciones): add vacation range manager screen
Add the Vacaciones manager screen per design ADR-6: an active-range
hero (name, days-remaining countdown, determinate progress bar, and a
per-alarm pause-impact line), a "PROGRAMADOS" upcoming-ranges list, an
"Add range" CTA, and a "Rangos pasados" history section. This is the
real destination WU8's Alarmas-root summary row pushes to, replacing
WU8's own temporary placeholder (_PantallaVacacionesTemporal, now
deleted).

EstadoAlarmas gains 4 pure query methods (rangoVacacionesActivo,
vacacionesProximas, vacacionesPasadas, impactoDeRango) -- read-only
over _alarmas/_vacaciones, no writes, no rescheduling, no native
bridge calls. impactoDeRango mirrors ServicioProgramacionAlarmas's own
pause predicate exactly, so the screen never disagrees with the
scheduler about which alarms are paused. ImpactoVacaciones joins
RangoVacaciones in alarma_musical.dart.

The add-range form (_EditorVacacionesSheet, _PickerButton) moved
verbatim from pantalla_alarmas.dart to its one remaining consumer.
estado_alarmas.dart's scheduling/snooze paths and the ringing screen's
dismiss guard are untouched; both test files pass unmodified.

New ARB keys (en/es only; other 11 locales are WU18's job):
vacationImpact{Paused,Continues}Label, vacationUpcomingSectionTitle,
vacationPastSectionTitle, addVacationRangeCta,
vacationNoActiveRangeHint.
2026-07-29 10:49:51 +02:00

710 lines
26 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';
class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
ServicioAlarmas? servicio,
PuertoAlarmasAndroid? android,
SharedPreferences? prefs,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
android = android ?? ServicioAlarmasAndroid(),
_prefs = prefs {
// 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;
static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada';
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();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
debugPrint('[PluriWave][alarmas] inicializar ERROR $e');
} finally {
_cargando = false;
notifyListeners();
}
}
Future<void> guardarAlarma(AlarmaMusical alarma) async {
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);
} catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
}
notifyListeners();
}
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',
);
}
}
}
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();
}
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
_error = null;
// The snooze anchors to the occurrence that is RINGING — never a future
// one. When the native fire works, the fire-time sync advances
// proximaEjecucion to the NEXT day before the user can even tap snooze,
// so anchoring to proximaEjecucion re-armed "posponer 3" a full day out
// (observed on-device: snooze armed for tomorrow 23:02). The ringing
// occurrence is the newest candidate not meaningfully in the future:
// snoozeOrigen (a re-snooze keeps the original anchor), then
// proximaEjecucion (watchdog path: still today's just-due occurrence),
// then ultimaEjecucionGestionada (native-fire path: the sync recorded
// the ringing occurrence there), then now.
final ahora = servicio.ahora();
final limite = ahora.add(
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
);
DateTime? sonando(DateTime? candidata) =>
candidata != null && !candidata.isAfter(limite) ? candidata : null;
final ejecucion =
sonando(alarma.snoozeOrigen) ??
sonando(alarma.proximaEjecucion) ??
sonando(alarma.ultimaEjecucionGestionada) ??
ahora;
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);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
}
notifyListeners();
}
Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma,
int minutos,
DateTime ejecucion,
) async {
_error = null;
final seguros = _snoozeSeguro(minutos);
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
debugPrint(
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucionHasta(
alarma.id,
ejecucion,
snoozeHasta,
);
_aplicar(config);
final actualizada = _buscarAlarma(alarma.id);
try {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
}
notifyListeners();
}
Future<void> finalizarEjecucion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
_error = null;
final alarma = _buscarAlarma(alarmaId);
final ejecucion =
alarma?.snoozeOrigen ??
alarma?.proximaEjecucion ??
alarma?.snoozeHasta ??
DateTime.now();
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();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [..._vacaciones, rango];
await guardarVacaciones(nuevos);
}
Future<void> eliminarRangoVacaciones(String id) async {
final nuevos = _vacaciones.where((v) => v.id != id).toList();
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();
}
/// 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();
}
/// 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();
}
for (final alarma in _alarmas) {
await android.programar(alarma);
}
}
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();
}
}