El editor dejaba la fecha clavada en hoy, asi que al escribir 05:30 siendo las 18:30 el candidato quedaba en el pasado, `calcularProxima` lo rechazaba con razon y la alarma se guardaba sin proxima ejecucion: nunca sonaba. En la practica solo se podian poner alarmas unicas para lo que quedaba de dia. `normalizarFechaUnica` resuelve el dia que el usuario realmente quiere, con la convencion de cualquier despertador: hora ya pasada -> manana, hora por llegar -> hoy. Una fecha elegida a proposito en el futuro no se toca nunca, y una fecha rancia salta a hoy/manana en vez de a `fecha + 1`, que seguiria en el pasado. La regla vive en el editor y NO dentro de `calcularProxima`: esa tiene que seguir siendo literal, porque el recalculo posterior al disparo y el motor nativo dependen de que una alarma unica vencida resuelva a null en vez de resucitar al dia siguiente. De paso el editor pasa a leer el reloj inyectado del servicio en vez de `DateTime.now()`, para que la vista previa, los limites del selector de fecha y el ajuste lean el mismo instante y se puedan fijar en las pruebas.
212 lines
7.3 KiB
Dart
212 lines
7.3 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,
|
|
),
|
|
};
|
|
}
|
|
|
|
/// Resolves the wall-clock date a ONE-TIME alarm should actually land on,
|
|
/// given the time the user just typed. Alarm clocks everywhere read "05:30"
|
|
/// at 18:30 as "05:30 tomorrow"; without this, `calcularProxima` correctly
|
|
/// refuses a candidate that is already in the past and the alarm silently
|
|
/// never fires. The rollover belongs here and NOT inside `calcularProxima`:
|
|
/// that one must stay literal, because the post-execution path and the
|
|
/// native scheduler both rely on a past one-shot resolving to null instead
|
|
/// of resurrecting itself on the next day.
|
|
///
|
|
/// A date the user picked deliberately in the future is never overridden.
|
|
/// A stale date (an old draft reopened days later) snaps to today/tomorrow
|
|
/// rather than to `fecha + 1`, which would still be in the past.
|
|
///
|
|
/// Day arithmetic goes through the DateTime constructor, never
|
|
/// `add(Duration(days: 1))` — see `_siguienteDia` for why.
|
|
DateTime normalizarFechaUnica({
|
|
required DateTime fecha,
|
|
required int hora,
|
|
required int minuto,
|
|
required DateTime ahora,
|
|
}) {
|
|
final elegido = DateTime(fecha.year, fecha.month, fecha.day, hora, minuto);
|
|
if (_sigueSiendoInminente(elegido, ahora)) return elegido;
|
|
|
|
final hoy = DateTime(ahora.year, ahora.month, ahora.day, hora, minuto);
|
|
return _sigueSiendoInminente(hoy, ahora)
|
|
? hoy
|
|
: DateTime(ahora.year, ahora.month, ahora.day + 1, hora, minuto);
|
|
}
|
|
|
|
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));
|
|
}
|