import '../modelos/alarma_musical.dart'; class ServicioProgramacionAlarmas { static const Duration toleranciaDisparoInminente = Duration(seconds: 90); DateTime? calcularProxima({ required AlarmaMusical alarma, required DateTime desde, List vacaciones = const [], List excepciones = const [], }) { if (!alarma.activa) return null; final diaBase = alarma.tipoProgramacion == TipoProgramacionAlarma.unica && alarma.fechaUnica != null ? alarma.fechaUnica! : desde; final inicio = DateTime( diaBase.year, diaBase.month, diaBase.day, alarma.hora, alarma.minuto, ); final primerCandidato = alarma.tipoProgramacion == TipoProgramacionAlarma.unica ? inicio : _sigueSiendoInminente(inicio, desde) ? inicio : _siguienteDia(inicio, alarma); return switch (alarma.tipoProgramacion) { TipoProgramacionAlarma.unica => _sigueSiendoInminente(primerCandidato, desde) && _esValida(alarma, primerCandidato, vacaciones, excepciones) ? _normalizarInminente(primerCandidato, desde) : null, TipoProgramacionAlarma.diaria => _buscarDiaria( alarma, primerCandidato, desde, vacaciones, excepciones, ), TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana( alarma, primerCandidato, desde, vacaciones, excepciones, ), }; } DateTime calcularSnooze(DateTime desde, int minutos) { final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5; return desde.add(Duration(minutes: seguro)); } DateTime? calcularSiguienteDespuesDeEjecucion({ required AlarmaMusical alarma, required DateTime ejecucion, List vacaciones = const [], List excepciones = const [], }) { if (!alarma.activa) return null; if (alarma.tipoProgramacion == TipoProgramacionAlarma.unica) return null; return calcularProxima( alarma: alarma.copyWith(limpiarSnooze: true), desde: ejecucion.add( toleranciaDisparoInminente + const Duration(milliseconds: 1), ), vacaciones: vacaciones, excepciones: excepciones, ); } bool estaEnVacaciones(DateTime fecha, List vacaciones) => vacaciones.any((rango) => rango.contiene(fecha)); DateTime? _buscarDiaria( AlarmaMusical alarma, DateTime candidato, DateTime desde, List vacaciones, List excepciones, ) { var actual = candidato; for (var i = 0; i < 370; i++) { if (_sigueSiendoInminente(actual, desde) && _esValida(alarma, actual, vacaciones, excepciones)) { return _normalizarInminente(actual, desde); } actual = _siguienteDia(actual, alarma); } return null; } DateTime? _buscarPorDiasSemana( AlarmaMusical alarma, DateTime candidato, DateTime desde, List vacaciones, List excepciones, ) { if (alarma.diasSemana.isEmpty) return null; var actual = candidato; for (var i = 0; i < 370; i++) { if (alarma.diasSemana.contains(actual.weekday) && _sigueSiendoInminente(actual, desde) && _esValida(alarma, actual, vacaciones, excepciones)) { return _normalizarInminente(actual, desde); } actual = _siguienteDia(actual, alarma); } return null; } /// Advances a candidate to the SAME wall-clock time on the next calendar /// day. `add(Duration(days: 1))` must never be used for this: Dart /// Duration arithmetic shifts the absolute instant by exactly 86400s, so /// crossing a DST transition drifts the wall hour by +-1h PERMANENTLY for /// the rest of the scan (verified: 2026-03-28 07:30 Europe/Madrid +1d -> /// 08:30). Calendar reconstruction preserves the alarm's wall-clock time /// through any transition — the same semantics as the native engine's /// Calendar.add(DAY_OF_YEAR, 1), keeping both sides in agreement. The /// DateTime constructor normalizes day/month/year overflow. DateTime _siguienteDia(DateTime actual, AlarmaMusical alarma) => DateTime( actual.year, actual.month, actual.day + 1, alarma.hora, alarma.minuto, ); bool _esValida( AlarmaMusical alarma, DateTime candidato, List vacaciones, List excepciones, ) { final ultimaGestionada = alarma.ultimaEjecucionGestionada; if (ultimaGestionada != null && _mismaEjecucion(ultimaGestionada, candidato)) { return false; } if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) { return false; } // Only a deliberate user skip ever removes a candidate occurrence. // Reliability-failure records share this same list/model (so the alarms // list can surface them per-alarm via `ultimaExcepcionPara`), but they // must never be mistaken for a skip — that would silently jump the // alarm to its NEXT occurrence instead of just flagging the failed one. return !excepciones.any( (excepcion) => excepcion.alarmaId == alarma.id && excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente && _mismaEjecucion(excepcion.ejecucion, candidato), ); } bool _mismaEjecucion(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day && a.hour == b.hour && a.minute == b.minute; bool _sigueSiendoInminente(DateTime candidato, DateTime desde) => candidato.isAfter(desde) || desde.difference(candidato) <= toleranciaDisparoInminente; DateTime _normalizarInminente(DateTime candidato, DateTime desde) => candidato.isAfter(desde) ? candidato : desde.add(const Duration(seconds: 2)); }