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 alarmas; final List vacaciones; final List 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 _cola = Future.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 _enCola(Future 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 cargar() => _enCola(() { _cache = null; _cacheRaw = null; return _configActual(); }); Future _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; final alarmas = parseListaTolerante( data['alarmas'] as List?, AlarmaMusical.fromJson, subsistema: 'alarmas', coleccion: 'alarmas', ); final vacaciones = parseListaTolerante( data['vacaciones'] as List?, RangoVacaciones.fromJson, subsistema: 'alarmas', coleccion: 'vacaciones', ); final excepciones = parseListaTolerante( 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 guardarAlarma( AlarmaMusical alarma, { List? vacaciones, List? excepciones, }) => _enCola(() async { final config = await _configActual(); final ahora = _reloj(); final vacacionesActuales = vacaciones ?? config.vacaciones; final excepcionesActuales = excepciones ?? config.excepciones; final alarmas = List.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 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 guardarVacaciones( List 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 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 sincronizarEjecucionesNativas( Map ejecuciones, ) { if (ejecuciones.isEmpty) return cargar(); return _enCola(() => _sincronizarEjecucionesNativasInterno(ejecuciones)); } Future _sincronizarEjecucionesNativasInterno( Map 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 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 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 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 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 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 _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 vacaciones, List 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 _recalcularLista( List alarmas, List vacaciones, List excepciones, ) => alarmas.map((a) => _recalcular(a, vacaciones, excepciones)).toList(); Future _resolverPrefs() async => _prefs ?? SharedPreferences.getInstance(); }