ExcepcionAlarma._esValida matched ANY exception tipo against an occurrence, treating it as a user skip. Only the 'skipNext' tipo existed until now, but the next commits reuse the same model to record scheduling-reliability failures per alarm (so the alarms list can surface them via ultimaExcepcionPara) -- without this guard, a recorded failure would be silently treated as if the user asked to skip that occurrence, corrupting scheduling. Adds tipo constants to ExcepcionAlarma for the upcoming failure kinds.
182 lines
5.9 KiB
Dart
182 lines
5.9 KiB
Dart
import '../modelos/alarma_musical.dart';
|
|
|
|
class ServicioProgramacionAlarmas {
|
|
static const Duration toleranciaDisparoInminente = Duration(seconds: 90);
|
|
|
|
DateTime? calcularProxima({
|
|
required AlarmaMusical alarma,
|
|
required DateTime desde,
|
|
List<RangoVacaciones> vacaciones = const [],
|
|
List<ExcepcionAlarma> 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<RangoVacaciones> vacaciones = const [],
|
|
List<ExcepcionAlarma> 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<RangoVacaciones> vacaciones) =>
|
|
vacaciones.any((rango) => rango.contiene(fecha));
|
|
|
|
DateTime? _buscarDiaria(
|
|
AlarmaMusical alarma,
|
|
DateTime candidato,
|
|
DateTime desde,
|
|
List<RangoVacaciones> vacaciones,
|
|
List<ExcepcionAlarma> 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<RangoVacaciones> vacaciones,
|
|
List<ExcepcionAlarma> 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<RangoVacaciones> vacaciones,
|
|
List<ExcepcionAlarma> 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));
|
|
}
|