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 '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(); // 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(); 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; _cacheRaw = raw; return config; } ConfiguracionAlarmas _parsear(String? raw) { if (raw == null || raw.trim().isEmpty) { return const ConfiguracionAlarmas( alarmas: [], vacaciones: [], excepciones: [], ); } try { final data = jsonDecode(raw) as Map; return ConfiguracionAlarmas( alarmas: (data['alarmas'] as List? ?? const []) .whereType() .map( (e) => AlarmaMusical.fromJson(Map.from(e)), ) .toList(), vacaciones: (data['vacaciones'] as List? ?? const []) .whereType() .map( (e) => RangoVacaciones.fromJson(Map.from(e)), ) .toList(), excepciones: (data['excepciones'] as List? ?? const []) .whereType() .map( (e) => ExcepcionAlarma.fromJson(Map.from(e)), ) .toList(), ); } catch (_) { return const ConfiguracionAlarmas( alarmas: [], vacaciones: [], excepciones: [], ); } } Future guardarAlarma( AlarmaMusical alarma, { List? vacaciones, List? excepciones, }) => _enCola(() async { final config = await _configActual(); final ahora = _reloj(); final alarmas = List.from(config.alarmas); final index = alarmas.indexWhere((a) => a.id == alarma.id); final normalizada = _recalcular( alarma.copyWith(creadaEn: alarma.creadaEn ?? ahora, actualizadaEn: ahora), vacaciones ?? config.vacaciones, excepciones ?? config.excepciones, ); if (index >= 0) { alarmas[index] = normalizada; } else { alarmas.add(normalizada); } final nuevo = ConfiguracionAlarmas( alarmas: alarmas, vacaciones: vacaciones ?? config.vacaciones, excepciones: excepciones ?? config.excepciones, ); await _guardar(nuevo); return nuevo; }); Future eliminarAlarma(String id) => _enCola(() async { final config = await _configActual(); final nuevo = ConfiguracionAlarmas( alarmas: config.alarmas.where((a) => a.id != id).toList(), vacaciones: config.vacaciones, excepciones: config.excepciones.where((e) => e.alarmaId != id).toList(), ); 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 alarmas = config.alarmas .map((a) => _recalcular(a, normalizadas, config.excepciones)) .toList(); final nuevo = ConfiguracionAlarmas( alarmas: alarmas, 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(); final alarmas = config.alarmas .map((a) => _recalcular(a, config.vacaciones, config.excepciones)) .toList(); 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: alarmas, 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 alarmas = config.alarmas .map( (a) => a.id == alarmaId ? _recalcular(a, config.vacaciones, excepciones) : a, ) .toList(); final nuevo = ConfiguracionAlarmas( alarmas: alarmas, 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: alarmas, 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: alarmas, 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; } 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, ); } Future _resolverPrefs() async => _prefs ?? SharedPreferences.getInstance(); }