The anchor fix stops NEW damage, but devices that ran the buggy build still carry a future occurrence in ultimaEjecucionGestionada in SharedPreferences. _esValida rejects any candidate matching it, so the affected alarm would keep skipping that day with nothing in the UI to explain it -- which reads as "still broken" rather than "fixed". _recalcular now drops an ultimaEjecucionGestionada that is meaningfully in the future. An occurrence cannot have been handled before it happens, so such a value is corrupt by definition, and dropping it can only ever restore a real future ring: the double-fire guard it also feeds needs a PAST occurrence to do its job, and those are untouched. Placed in the recalculation that every load and every mutation already funnels through, so an affected alarm heals on the next app open with no user action -- no delete-and-recreate. Tests: 1122 -> 1124, including one proving a genuine past occurrence is still preserved.
620 lines
22 KiB
Dart
620 lines
22 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);
|
|
// Self-heal for state poisoned before the Detener anchor fix: a stop
|
|
// that closed a FUTURE occurrence wrote it into
|
|
// ultimaEjecucionGestionada, and _esValida rejects any candidate
|
|
// matching it -- so the alarm silently skips that day forever after,
|
|
// with nothing in the UI to explain it. An occurrence cannot have been
|
|
// handled before it happens, so a value meaningfully in the future is
|
|
// corrupt by definition and safe to drop: it can only ever suppress a
|
|
// real future ring, never prevent a double-fire (which needs a PAST
|
|
// occurrence to guard). Placed here, in the recalculation every load and
|
|
// every mutation already funnels through, so an affected alarm heals on
|
|
// the next app open with no user action.
|
|
final gestionada = alarma.ultimaEjecucionGestionada;
|
|
final gestionadaCorrupta =
|
|
gestionada != null &&
|
|
gestionada.isAfter(
|
|
ahora.add(ServicioProgramacionAlarmas.toleranciaDisparoInminente),
|
|
);
|
|
final saneada =
|
|
gestionadaCorrupta
|
|
? alarma.copyWith(limpiarUltimaEjecucionGestionada: true)
|
|
: alarma;
|
|
final proxima = _programacion.calcularProxima(
|
|
alarma: saneada,
|
|
desde: ahora,
|
|
vacaciones: vacaciones,
|
|
excepciones: excepciones,
|
|
);
|
|
return saneada.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();
|
|
}
|