Files
pluriwave/lib/servicios/servicio_alarmas.dart
T
FreeTLab 65c1ac2085
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms
A single malformed alarm entry (bad/missing id, wrong type) used to
discard the ENTIRE persisted list on next load, and a fully unparseable
payload let the periodic recalculation silently overwrite it with an
empty one -- both destroyed valid alarms with no user action.

Adds a shared per-entry tolerant-parse helper (persistencia_tolerante.dart)
that skips and logs only the bad entry; ServicioAlarmas now normalizes its
cached raw after a partial load (no dirty-guard thrash) and sets a
degraded-read flag after a total decode failure that suppresses automatic
writes until a good read or an explicit user mutation restores authority.
2026-07-11 12:27:39 +02:00

527 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();
// 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();
}