Completes the bridge the native side already exposed. AlarmScheduler and PluriWaveAlarmService record a pre-notice that could not be armed, a refused foreground-service start, and a per-alarm reschedule that failed after a reboot -- but nothing read them, so all three still ended at logcat. EstadoAlarmas now drains them at startup and turns each into a per-alarm exception, which the card already knows how to mark. An alarm that never reached the OS stops looking identical to one that did. The read is deliberately tolerant: a failure to read is logged and swallowed, never surfaced as an alarm error, so a diagnostics gap cannot masquerade as a scheduling problem.
599 lines
21 KiB
Dart
599 lines
21 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../modelos/alarma_musical.dart';
|
|
import '../modelos/emisora.dart';
|
|
import 'persistencia_tolerante.dart';
|
|
import 'servicio_programacion_alarmas.dart';
|
|
|
|
class ConfiguracionAlarmas {
|
|
const ConfiguracionAlarmas({
|
|
required this.alarmas,
|
|
required this.vacaciones,
|
|
required this.excepciones,
|
|
});
|
|
|
|
final List<AlarmaMusical> alarmas;
|
|
final List<RangoVacaciones> vacaciones;
|
|
final List<ExcepcionAlarma> excepciones;
|
|
}
|
|
|
|
class ServicioAlarmas {
|
|
ServicioAlarmas({
|
|
ServicioProgramacionAlarmas? programacion,
|
|
SharedPreferences? prefs,
|
|
DateTime Function()? reloj,
|
|
}) : _programacion = programacion ?? ServicioProgramacionAlarmas(),
|
|
_prefs = prefs,
|
|
_reloj = reloj ?? DateTime.now;
|
|
|
|
static const _keyConfig = 'alarmas_musicales_v1';
|
|
final ServicioProgramacionAlarmas _programacion;
|
|
final SharedPreferences? _prefs;
|
|
final DateTime Function() _reloj;
|
|
final _uuid = const Uuid();
|
|
|
|
/// Current time through the injected clock, so callers that need "now"
|
|
/// (e.g. the ring screen's snooze anchor) stay testable with the same
|
|
/// fixture clock the service itself computes with.
|
|
DateTime ahora() => _reloj();
|
|
|
|
// In-memory cache + single-writer queue (Design 3.5 / S3-R7): every
|
|
// mutation runs serialized through [_enCola] and reads [_cache], killing
|
|
// the read-modify-write race the old cargar()-before-each-mutation had.
|
|
ConfiguracionAlarmas? _cache;
|
|
String? _cacheRaw;
|
|
Future<void> _cola = Future<void>.value();
|
|
|
|
// persistence-resilience (D4): set when the top-level payload could not
|
|
// be decoded at all (vs. an individual entry inside it). While true, the
|
|
// AUTOMATIC writer (recalcularTodas) must never reach disk -- only an
|
|
// explicit user mutation (which always funnels through _guardar) may
|
|
// overwrite a payload we could not read. Cleared by _guardar and by the
|
|
// next successful (even if partial) read.
|
|
bool _lecturaAlarmasDegradada = false;
|
|
|
|
Future<T> _enCola<T>(Future<T> Function() accion) {
|
|
final resultado = _cola.then((_) => accion());
|
|
_cola = resultado.then((_) {}, onError: (_) {});
|
|
return resultado;
|
|
}
|
|
|
|
/// Re-reads from persistent storage (refreshing the cache) so writes done
|
|
/// outside this service — e.g. a settings import that rewrites the raw
|
|
/// key — are always picked up.
|
|
Future<ConfiguracionAlarmas> cargar() => _enCola(() {
|
|
_cache = null;
|
|
_cacheRaw = null;
|
|
return _configActual();
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> _configActual() async {
|
|
final existente = _cache;
|
|
if (existente != null) return existente;
|
|
final prefs = await _resolverPrefs();
|
|
final raw = prefs.getString(_keyConfig);
|
|
final config = _parsear(raw);
|
|
_cache = config;
|
|
return config;
|
|
}
|
|
|
|
/// Parses the persisted [raw] payload with per-entry tolerance
|
|
/// (persistence-resilience D1/D2) and updates [_cacheRaw] /
|
|
/// [_lecturaAlarmasDegradada] as a side effect, since each outcome needs
|
|
/// a DIFFERENT cached-raw value:
|
|
/// - top-level decode failure (bad JSON, or any container-level field of
|
|
/// the wrong shape) -> DEGRADED: empty config, [_cacheRaw] keeps the
|
|
/// corrupt [raw] untouched, [_lecturaAlarmasDegradada] set so
|
|
/// `recalcularTodas` never overwrites it (D4).
|
|
/// - decodes, but one or more entries are malformed -> PARTIAL: only the
|
|
/// survivors are kept, [_cacheRaw] is normalized to their own
|
|
/// serialization -- NOT the corrupt raw (D3) -- so the automatic
|
|
/// writer's dirty-guard compares against a coherent baseline instead of
|
|
/// re-firing on every 60s tick.
|
|
/// - fully healthy payload -> unchanged behavior; clears the flag if a
|
|
/// previous read had set it (suppression lifts on next successful
|
|
/// read).
|
|
ConfiguracionAlarmas _parsear(String? raw) {
|
|
if (raw == null || raw.trim().isEmpty) {
|
|
_lecturaAlarmasDegradada = false;
|
|
_cacheRaw = raw;
|
|
return const ConfiguracionAlarmas(
|
|
alarmas: [],
|
|
vacaciones: [],
|
|
excepciones: [],
|
|
);
|
|
}
|
|
try {
|
|
final data = jsonDecode(raw) as Map<String, dynamic>;
|
|
final alarmas = parseListaTolerante<AlarmaMusical>(
|
|
data['alarmas'] as List?,
|
|
AlarmaMusical.fromJson,
|
|
subsistema: 'alarmas',
|
|
coleccion: 'alarmas',
|
|
);
|
|
final vacaciones = parseListaTolerante<RangoVacaciones>(
|
|
data['vacaciones'] as List?,
|
|
RangoVacaciones.fromJson,
|
|
subsistema: 'alarmas',
|
|
coleccion: 'vacaciones',
|
|
);
|
|
final excepciones = parseListaTolerante<ExcepcionAlarma>(
|
|
data['excepciones'] as List?,
|
|
ExcepcionAlarma.fromJson,
|
|
subsistema: 'alarmas',
|
|
coleccion: 'excepciones',
|
|
);
|
|
final config = ConfiguracionAlarmas(
|
|
alarmas: alarmas.validas,
|
|
vacaciones: vacaciones.validas,
|
|
excepciones: excepciones.validas,
|
|
);
|
|
_lecturaAlarmasDegradada = false;
|
|
final huboSaltos =
|
|
alarmas.saltadas > 0 ||
|
|
vacaciones.saltadas > 0 ||
|
|
excepciones.saltadas > 0;
|
|
_cacheRaw = huboSaltos ? _serializar(config) : raw;
|
|
return config;
|
|
} catch (e) {
|
|
_lecturaAlarmasDegradada = true;
|
|
_cacheRaw = raw;
|
|
registrarSaltoPersistencia(
|
|
subsistema: 'alarmas',
|
|
detalle: _keyConfig,
|
|
razon: e.toString(),
|
|
);
|
|
return const ConfiguracionAlarmas(
|
|
alarmas: [],
|
|
vacaciones: [],
|
|
excepciones: [],
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> guardarAlarma(
|
|
AlarmaMusical alarma, {
|
|
List<RangoVacaciones>? vacaciones,
|
|
List<ExcepcionAlarma>? excepciones,
|
|
}) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final ahora = _reloj();
|
|
final vacacionesActuales = vacaciones ?? config.vacaciones;
|
|
final excepcionesActuales = excepciones ?? config.excepciones;
|
|
final alarmas = List<AlarmaMusical>.from(config.alarmas);
|
|
final index = alarmas.indexWhere((a) => a.id == alarma.id);
|
|
final actualizada = alarma.copyWith(
|
|
creadaEn: alarma.creadaEn ?? ahora,
|
|
actualizadaEn: ahora,
|
|
);
|
|
if (index >= 0) {
|
|
alarmas[index] = actualizada;
|
|
} else {
|
|
alarmas.add(actualizada);
|
|
}
|
|
// Recalculate every alarm, not just the one just saved: siblings can
|
|
// hold a proximaEjecucion snapshot from the last periodic tick that is
|
|
// stale (even past-due), which would otherwise outrank a freshly
|
|
// created/activated/edited alarm when EstadoAlarmas.proximaAlarma sorts
|
|
// by proximaProgramable.
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: _recalcularLista(
|
|
alarmas,
|
|
vacacionesActuales,
|
|
excepcionesActuales,
|
|
),
|
|
vacaciones: vacacionesActuales,
|
|
excepciones: excepcionesActuales,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> eliminarAlarma(String id) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final restantes = config.alarmas.where((a) => a.id != id).toList();
|
|
final excepciones =
|
|
config.excepciones.where((e) => e.alarmaId != id).toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: _recalcularLista(restantes, config.vacaciones, excepciones),
|
|
vacaciones: config.vacaciones,
|
|
excepciones: excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> guardarVacaciones(
|
|
List<RangoVacaciones> vacaciones,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final normalizadas =
|
|
vacaciones.map((v) => v.normalizado()).toList()
|
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: _recalcularLista(
|
|
config.alarmas,
|
|
normalizadas,
|
|
config.excepciones,
|
|
),
|
|
vacaciones: normalizadas,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
RangoVacaciones crearRangoVacaciones({
|
|
required DateTime inicio,
|
|
required DateTime fin,
|
|
String? nombre,
|
|
}) {
|
|
final rango = RangoVacaciones(
|
|
id: _uuid.v4(),
|
|
nombre:
|
|
(nombre == null || nombre.trim().isEmpty)
|
|
? 'Vacaciones'
|
|
: nombre.trim(),
|
|
inicio: inicio,
|
|
fin: fin,
|
|
);
|
|
return rango.normalizado();
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
|
|
final config = await _configActual();
|
|
// persistence-resilience (D4): a degraded top-level read must never
|
|
// let this AUTOMATIC writer reach disk -- only an explicit user
|
|
// mutation (guardarAlarma/eliminarAlarma/etc., via _guardar) may
|
|
// persist over a payload we could not read.
|
|
if (_lecturaAlarmasDegradada) return config;
|
|
final alarmas = _recalcularLista(
|
|
config.alarmas,
|
|
config.vacaciones,
|
|
config.excepciones,
|
|
);
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: alarmas,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
// Dirty-guard (S3-R5): this runs every minute from the refresh timer;
|
|
// skip the SharedPreferences write when nothing actually changed.
|
|
final nuevoRaw = _serializar(nuevo);
|
|
final actualRaw = _cacheRaw ?? _serializar(config);
|
|
if (nuevoRaw == actualRaw) return config;
|
|
await _guardar(nuevo, raw: nuevoRaw);
|
|
return nuevo;
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> sincronizarEjecucionesNativas(
|
|
Map<String, DateTime> ejecuciones,
|
|
) {
|
|
if (ejecuciones.isEmpty) return cargar();
|
|
return _enCola(() => _sincronizarEjecucionesNativasInterno(ejecuciones));
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> _sincronizarEjecucionesNativasInterno(
|
|
Map<String, DateTime> ejecuciones,
|
|
) async {
|
|
final config = await _configActual();
|
|
final ahora = _reloj();
|
|
var huboCambios = false;
|
|
final alarmas =
|
|
config.alarmas.map((alarma) {
|
|
final gestionadaEn = ejecuciones[alarma.id];
|
|
if (gestionadaEn == null) return alarma;
|
|
final ultima = alarma.ultimaEjecucionGestionada;
|
|
if (ultima != null && !gestionadaEn.isAfter(ultima)) return alarma;
|
|
|
|
final proxima = alarma.proximaProgramable;
|
|
if (proxima != null &&
|
|
proxima.isAfter(
|
|
gestionadaEn.add(
|
|
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
|
),
|
|
)) {
|
|
return alarma;
|
|
}
|
|
|
|
final siguiente = _programacion.calcularSiguienteDespuesDeEjecucion(
|
|
alarma: alarma,
|
|
ejecucion: gestionadaEn,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
huboCambios = true;
|
|
return alarma.copyWith(
|
|
activa:
|
|
alarma.tipoProgramacion == TipoProgramacionAlarma.unica
|
|
? false
|
|
: alarma.activa,
|
|
proximaEjecucion: siguiente,
|
|
limpiarProximaEjecucion: true,
|
|
limpiarSnooze: true,
|
|
ultimaEjecucionGestionada: gestionadaEn,
|
|
actualizadaEn: ahora,
|
|
);
|
|
}).toList();
|
|
|
|
if (!huboCambios) return config;
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones),
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> saltarProxima(
|
|
String alarmaId,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
|
|
final proxima = alarma.proximaEjecucion;
|
|
if (proxima == null) return config;
|
|
|
|
final excepciones = [
|
|
...config.excepciones,
|
|
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'),
|
|
];
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: _recalcularLista(config.alarmas, config.vacaciones, excepciones),
|
|
vacaciones: config.vacaciones,
|
|
excepciones: excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
/// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas-
|
|
/// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model
|
|
/// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara`
|
|
/// surfaces it on the exact alarm card affected instead of only a
|
|
/// transient, alarm-agnostic message. A previous FAILURE record for the
|
|
/// same alarm is replaced (only the latest attempt's outcome matters) --
|
|
/// any `skipNext` exception for this or other alarms is left untouched.
|
|
/// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only
|
|
/// treats `tipoSaltoSiguiente` as an actual skip.
|
|
Future<ConfiguracionAlarmas> registrarFalloProgramacion(
|
|
String alarmaId,
|
|
DateTime ejecucion,
|
|
String tipo,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final excepciones = [
|
|
..._sinFalloPrevio(config.excepciones, alarmaId),
|
|
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo),
|
|
];
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: config.alarmas,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
/// Clears the outstanding failure record for [alarmaId] ONLY when its
|
|
/// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind
|
|
/// succeeded). Type-scoped on purpose: a successful main-alarm schedule
|
|
/// call proves nothing about the pre-notice or foreground-service
|
|
/// subsystems, so it must never clear a failure recorded for those. No-op
|
|
/// when there is nothing to clear or the recorded tipo does not match.
|
|
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
|
|
String alarmaId,
|
|
String tipo,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final actual = config.excepciones.where((e) => e.alarmaId == alarmaId);
|
|
final tieneEseTipo = actual.any((e) => e.tipo == tipo);
|
|
if (!tieneEseTipo) return config;
|
|
final excepciones =
|
|
config.excepciones
|
|
.where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo))
|
|
.toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: config.alarmas,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
List<ExcepcionAlarma> _sinFalloPrevio(
|
|
List<ExcepcionAlarma> excepciones,
|
|
String alarmaId,
|
|
) =>
|
|
excepciones
|
|
.where(
|
|
(e) =>
|
|
!(e.alarmaId == alarmaId &&
|
|
ExcepcionAlarma.tiposFallo.contains(e.tipo)),
|
|
)
|
|
.toList();
|
|
|
|
Future<ConfiguracionAlarmas> posponerEjecucion(
|
|
String alarmaId,
|
|
DateTime ejecucion,
|
|
int minutos,
|
|
) async {
|
|
// Unified snooze anchor (Design 2.2): occurrence + minutes, clamped to
|
|
// now + minutes when the target already passed. Matches the native
|
|
// AlarmScheduler.snooze/postponeNext semantics so both layers always
|
|
// land on the same re-fire time.
|
|
final seguros = minutos.clamp(1, 120);
|
|
final objetivo = ejecucion.add(Duration(minutes: seguros));
|
|
final ahora = _reloj();
|
|
final snoozeHasta =
|
|
objetivo.isAfter(ahora)
|
|
? objetivo
|
|
: ahora.add(Duration(minutes: seguros));
|
|
return posponerEjecucionHasta(alarmaId, ejecucion, snoozeHasta);
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> posponerEjecucionHasta(
|
|
String alarmaId,
|
|
DateTime ejecucion,
|
|
DateTime snoozeHasta,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final ahora = _reloj();
|
|
final alarmas =
|
|
config.alarmas
|
|
.map(
|
|
(a) =>
|
|
a.id == alarmaId
|
|
? a.copyWith(
|
|
snoozeHasta: snoozeHasta,
|
|
snoozeOrigen: ejecucion,
|
|
ultimaEjecucionGestionada: ejecucion,
|
|
actualizadaEn: ahora,
|
|
)
|
|
: a,
|
|
)
|
|
.toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones),
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> completarEjecucion(
|
|
String alarmaId,
|
|
DateTime ejecucion,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final ahora = _reloj();
|
|
final alarmas =
|
|
config.alarmas.map((a) {
|
|
if (a.id != alarmaId) return a;
|
|
final siguiente = _programacion.calcularSiguienteDespuesDeEjecucion(
|
|
alarma: a,
|
|
ejecucion: ejecucion,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
return a.copyWith(
|
|
activa:
|
|
a.tipoProgramacion == TipoProgramacionAlarma.unica
|
|
? false
|
|
: a.activa,
|
|
proximaEjecucion: siguiente,
|
|
limpiarProximaEjecucion: true,
|
|
limpiarSnooze: true,
|
|
ultimaEjecucionGestionada: ejecucion,
|
|
actualizadaEn: ahora,
|
|
);
|
|
}).toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: _recalcularLista(alarmas, config.vacaciones, config.excepciones),
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
AlarmaMusical crearAlarma({
|
|
required String nombre,
|
|
required int hora,
|
|
required int minuto,
|
|
required TipoProgramacionAlarma tipoProgramacion,
|
|
required List<int> diasSemana,
|
|
DateTime? fechaUnica,
|
|
Emisora? emisora,
|
|
Emisora? emisoraFallback,
|
|
bool sonarEnVacaciones = true,
|
|
int snoozeMinutos = 5,
|
|
double volumen = 0.85,
|
|
SonidoInternoAlarma sonidoInterno = SonidoInternoAlarma.amanecer,
|
|
}) {
|
|
final ahora = _reloj();
|
|
return AlarmaMusical(
|
|
id: _uuid.v4(),
|
|
nombre: nombre,
|
|
hora: hora,
|
|
minuto: minuto,
|
|
tipoProgramacion: tipoProgramacion,
|
|
diasSemana: diasSemana,
|
|
fechaUnica: fechaUnica,
|
|
emisora: emisora,
|
|
emisoraFallback: emisoraFallback,
|
|
sonarEnVacaciones: sonarEnVacaciones,
|
|
snoozeMinutos: snoozeMinutos,
|
|
volumen: volumen,
|
|
sonidoInterno: sonidoInterno,
|
|
creadaEn: ahora,
|
|
actualizadaEn: ahora,
|
|
);
|
|
}
|
|
|
|
/// Persists [config] and refreshes the in-memory cache (the cache is
|
|
/// "invalidated" by replacing it with the just-written state).
|
|
Future<void> _guardar(ConfiguracionAlarmas config, {String? raw}) async {
|
|
final serializado = raw ?? _serializar(config);
|
|
final prefs = await _resolverPrefs();
|
|
await prefs.setString(_keyConfig, serializado);
|
|
_cache = config;
|
|
_cacheRaw = serializado;
|
|
// persistence-resilience (D4): every explicit mutation funnels through
|
|
// here, so this is where write authority is restored after a degraded
|
|
// read -- user intent wins over a suppressed automatic writer.
|
|
_lecturaAlarmasDegradada = false;
|
|
}
|
|
|
|
String _serializar(ConfiguracionAlarmas config) => jsonEncode({
|
|
'alarmas': config.alarmas.map((a) => a.toJson()).toList(),
|
|
'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(),
|
|
'excepciones': config.excepciones.map((e) => e.toJson()).toList(),
|
|
});
|
|
|
|
AlarmaMusical _recalcular(
|
|
AlarmaMusical alarma,
|
|
List<RangoVacaciones> vacaciones,
|
|
List<ExcepcionAlarma> excepciones,
|
|
) {
|
|
final ahora = _reloj();
|
|
// S2-R5: a disabled alarm must not keep a pending snooze; clearing it
|
|
// here guarantees the snoozed occurrence dies with the alarm.
|
|
final snoozeActivo =
|
|
alarma.activa &&
|
|
alarma.snoozeHasta != null &&
|
|
alarma.snoozeHasta!.isAfter(ahora);
|
|
final proxima = _programacion.calcularProxima(
|
|
alarma: alarma,
|
|
desde: ahora,
|
|
vacaciones: vacaciones,
|
|
excepciones: excepciones,
|
|
);
|
|
return alarma.copyWith(
|
|
proximaEjecucion: proxima,
|
|
limpiarProximaEjecucion: true,
|
|
limpiarSnooze: !snoozeActivo,
|
|
);
|
|
}
|
|
|
|
// Every mutation that can shift "which alarm fires next" (create, edit,
|
|
// activate/deactivate, delete, fire, snooze, skip) recalculates the whole
|
|
// list, not just the alarm it touched directly. Siblings otherwise keep a
|
|
// proximaEjecucion snapshot from the last periodic tick, which can be
|
|
// stale (or already past-due) and wrongly outrank a freshly updated alarm
|
|
// wherever proximaProgramable values are compared (EstadoAlarmas.proximaAlarma).
|
|
List<AlarmaMusical> _recalcularLista(
|
|
List<AlarmaMusical> alarmas,
|
|
List<RangoVacaciones> vacaciones,
|
|
List<ExcepcionAlarma> excepciones,
|
|
) => alarmas.map((a) => _recalcular(a, vacaciones, excepciones)).toList();
|
|
|
|
Future<SharedPreferences> _resolverPrefs() async =>
|
|
_prefs ?? SharedPreferences.getInstance();
|
|
}
|