Files
pluriwave/lib/servicios/servicio_alarmas.dart
T
FreeTLab 9c7cf4e261
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
fix(alarm): anchor snooze to the ringing occurrence, never a future one
posponerAlarma anchored the snooze to snoozeOrigen ?? proximaEjecucion,
but once the native fire path works, the fire-time sync records the
handled occurrence and recalculation advances proximaEjecucion to the
NEXT day before the user can even tap snooze on the still-ringing
screen. "Posponer 3" therefore armed the snooze a full day out
(captured on-device: snoozeCountdown remaining=1443 minutes). The bug
was invisible before because the broken delivery path never advanced
proximaEjecucion while ringing — each fix unmasked the next.

The anchor is now the newest occurrence that is not meaningfully in
the future (shared 90s imminence window): snoozeOrigen for re-snoozes,
proximaEjecucion on the watchdog path where it is still today's
just-due occurrence, ultimaEjecucionGestionada on the native-fire path
where the sync recorded the ringing occurrence, then now. ServicioAlarmas
exposes ahora() so the anchor uses the same injectable clock as the
rest of the scheduling math. Test fixtures that snoozed half an hour
before the ring — a state the ringing screen can never be in, since it
is posponerAlarma's only production caller — now move the clock to
ring time, preserving their original expectations.
2026-07-12 23:17:26 +02:00

532 lines
18 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;
});
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();
}