import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../servicios/servicio_alarmas.dart'; import '../servicios/servicio_alarmas_android.dart'; class EstadoAlarmas extends ChangeNotifier { EstadoAlarmas({ ServicioAlarmas? servicio, PuertoAlarmasAndroid? android, SharedPreferences? prefs, bool iniciarAutomaticamente = true, }) : servicio = servicio ?? ServicioAlarmas(prefs: prefs), android = android ?? ServicioAlarmasAndroid(), _prefs = prefs { // Decision 2.1 (snooze sync): the native layer reports its own snoozes // back through alarmFired/snoozed; record them here so the Flutter // config stays the single source of truth. _eventosNativosSub = this.android.eventosAlarma.listen( _alRecibirEventoNativo, ); if (iniciarAutomaticamente) { inicializar(); } } final ServicioAlarmas servicio; final PuertoAlarmasAndroid android; final SharedPreferences? _prefs; static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada'; List _alarmas = []; List _vacaciones = []; List _excepciones = []; DiagnosticoAlarmasAndroid? _diagnostico; Timer? _refresco; Timer? _vigilancia; StreamSubscription? _eventosNativosSub; final _alarmasVencidasController = StreamController.broadcast(); final Set _ejecucionesEmitidas = {}; static const _margenDisparoLocal = Duration(seconds: 45); // Bounds for _ejecucionesEmitidas (S3-R6): entries older than the // retention window are pruned; the set never exceeds the cap. static const _retencionEjecucionesEmitidas = Duration(hours: 24); @visibleForTesting static const maxEjecucionesEmitidas = 200; bool _cargando = false; String? _error; List get alarmas => List.unmodifiable(_alarmas); List get vacaciones => List.unmodifiable(_vacaciones); List get excepciones => List.unmodifiable(_excepciones); DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico; bool get cargando => _cargando; String? get error => _error; Stream get alarmasVencidasStream => _alarmasVencidasController.stream; AlarmaMusical? get proximaAlarma { final candidatas = _alarmas.where((a) => a.activa && a.proximaProgramable != null).toList() ..sort( (a, b) => a.proximaProgramable!.compareTo(b.proximaProgramable!), ); return candidatas.isEmpty ? null : candidatas.first; } Future inicializar() async { debugPrint('[PluriWave][alarmas] inicializar'); _cargando = true; _error = null; notifyListeners(); try { await _sincronizarEjecucionesGestionadasPorAndroid(); final config = await servicio.recalcularTodas(); _aplicar(config); debugPrint( '[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}', ); await _sincronizarTodas(); await cargarDiagnostico(); _activarRefresco(); } catch (e) { _error = 'No se pudieron cargar las alarmas: $e'; debugPrint('[PluriWave][alarmas] inicializar ERROR $e'); } finally { _cargando = false; notifyListeners(); } } Future guardarAlarma(AlarmaMusical alarma) async { debugPrint( '[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}', ); final config = await servicio.guardarAlarma(alarma); _aplicar(config); try { final guardada = _alarmas.firstWhere((a) => a.id == alarma.id); await _solicitarPermisosNecesariosParaAlarma(); debugPrint( '[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}', ); await android.programar(guardada); } catch (e) { _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; } notifyListeners(); } Future refrescarProgramacion() async { debugPrint('[PluriWave][alarmas] refrescar programacion'); final config = await servicio.recalcularTodas(); _aplicar(config); debugPrint( '[PluriWave][alarmas] proxima tras refrescar=${proximaAlarma?.id} ${proximaAlarma?.proximaEjecucion?.toIso8601String()}', ); await _sincronizarTodas(); notifyListeners(); } Future cargarPersistidasSinRecalcular() async { final config = await servicio.cargar(); _aplicar(config); notifyListeners(); } void marcarEjecucionGestionada(AlarmaMusical alarma) { final proxima = alarma.proximaProgramable; if (proxima == null) return; final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}'; _registrarEjecucionEmitida(key); debugPrint( '[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}', ); } @visibleForTesting int get ejecucionesEmitidasLength => _ejecucionesEmitidas.length; /// Forwards the UI localizations to the native bridge so alarm and station /// names sent to Android follow the app locale (Decision 3.2 — replaces /// the old static `ServicioAlarmasAndroid.configurarLocalizaciones`). void configurarLocalizaciones(AppLocalizations l10n) { android.configurarLocalizaciones(l10n); } Future eliminarAlarma(String id) async { debugPrint('[PluriWave][alarmas] eliminar id=$id'); final config = await servicio.eliminarAlarma(id); _aplicar(config); await android.detenerSonidoNativo(id); await android.cancelar(id); notifyListeners(); } Future cambiarActiva(AlarmaMusical alarma, bool activa) async { await guardarAlarma(alarma.copyWith(activa: activa)); } Future saltarProxima(String alarmaId) async { debugPrint('[PluriWave][alarmas] saltar proxima id=$alarmaId'); final config = await servicio.saltarProxima(alarmaId); _aplicar(config); AlarmaMusical? alarma; for (final item in _alarmas) { if (item.id == alarmaId) { alarma = item; break; } } if (alarma != null) { await android.programar(alarma); } notifyListeners(); } Future guardarVacaciones(List vacaciones) async { debugPrint( '[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}', ); final config = await servicio.guardarVacaciones(vacaciones); _aplicar(config); await _sincronizarTodas(); notifyListeners(); } Future posponerAlarma(AlarmaMusical alarma, int minutos) async { final ejecucion = alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now(); debugPrint( '[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}', ); await android.ocultarNotificacionAlarma(alarma.id); final config = await servicio.posponerEjecucion( alarma.id, ejecucion, minutos, ); _aplicar(config); final actualizada = _buscarAlarma(alarma.id); if (actualizada != null) { await android.programar(actualizada); } notifyListeners(); } Future posponerProximaDesdePreaviso( AlarmaMusical alarma, int minutos, DateTime ejecucion, ) async { final seguros = _snoozeSeguro(minutos); final snoozeHasta = ejecucion.add(Duration(minutes: seguros)); debugPrint( '[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}', ); await android.ocultarNotificacionAlarma(alarma.id); final config = await servicio.posponerEjecucionHasta( alarma.id, ejecucion, snoozeHasta, ); _aplicar(config); final actualizada = _buscarAlarma(alarma.id); if (actualizada != null) { await android.programar(actualizada); } notifyListeners(); } Future finalizarEjecucion(String alarmaId) async { debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId'); final alarma = _buscarAlarma(alarmaId); final ejecucion = alarma?.snoozeOrigen ?? alarma?.proximaEjecucion ?? alarma?.snoozeHasta ?? DateTime.now(); await android.ocultarNotificacionAlarma(alarmaId); final config = await servicio.completarEjecucion(alarmaId, ejecucion); _aplicar(config); await _sincronizarTodas(); notifyListeners(); } Future crearRangoVacaciones(RangoVacaciones rango) async { final nuevos = [..._vacaciones, rango]; await guardarVacaciones(nuevos); } Future eliminarRangoVacaciones(String id) async { final nuevos = _vacaciones.where((v) => v.id != id).toList(); await guardarVacaciones(nuevos); } ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) { final candidatas = _excepciones.where((e) => e.alarmaId == alarmaId).toList() ..sort((a, b) => b.ejecucion.compareTo(a.ejecucion)); return candidatas.isEmpty ? null : candidatas.first; } Future cargarDiagnostico() async { try { _diagnostico = await android.diagnostico(); } catch (e) { debugPrint('[PluriWave][alarmas] diagnostico ERROR $e'); _diagnostico = null; } notifyListeners(); } /// Records a snooze the native layer performed by itself (Decision 2.1). /// The native scheduler already re-registered setAlarmClock, so this only /// persists the canonical state — it MUST NOT call android.programar again. Future _alRecibirEventoNativo(EventoAlarmaAndroid evento) async { if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return; if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return; final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis); final origen = evento.occurrenceAtMillis > 0 ? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis) : hasta.subtract(Duration(minutes: evento.snoozeMinutes)); debugPrint( '[PluriWave][alarmas] snooze nativo id=${evento.alarmaId} hasta=${hasta.toIso8601String()}', ); try { final config = await servicio.posponerEjecucionHasta( evento.alarmaId, origen, hasta, ); _aplicar(config); notifyListeners(); } catch (e) { debugPrint('[PluriWave][alarmas] snooze nativo ERROR $e'); } } Future _sincronizarEjecucionesGestionadasPorAndroid() async { try { final ejecuciones = await android.obtenerEjecucionesNativasGestionadas(); if (ejecuciones.isNotEmpty) { final config = await servicio.sincronizarEjecucionesNativas({ for (final ejecucion in ejecuciones) ejecucion.alarmaId: ejecucion.gestionadaEn, }); _aplicar(config); debugPrint( '[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}', ); } } catch (e) { debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e'); } await _importarSnoozesNativosActivos(); } /// Cold-start half of Decision 2.1: imports snoozes the native scheduler /// performed while the Flutter engine was dead, before any recalculation /// could erase them. Future _importarSnoozesNativosActivos() async { try { final snoozes = await android.obtenerEstadoSnoozeNativo(); if (snoozes.isEmpty) return; final ahora = DateTime.now(); var config = await servicio.cargar(); var huboCambios = false; for (final snooze in snoozes) { if (!snooze.snoozeHasta.isAfter(ahora)) continue; AlarmaMusical? alarma; for (final candidata in config.alarmas) { if (candidata.id == snooze.alarmaId) { alarma = candidata; break; } } if (alarma == null || !alarma.activa) continue; if (alarma.snoozeHasta == snooze.snoozeHasta) continue; config = await servicio.posponerEjecucionHasta( snooze.alarmaId, snooze.snoozeOrigen, snooze.snoozeHasta, ); huboCambios = true; } if (huboCambios) { _aplicar(config); debugPrint( '[PluriWave][alarmas] snoozes nativos importados count=${snoozes.length}', ); } } catch (e) { debugPrint('[PluriWave][alarmas] importar snoozes nativos ERROR $e'); } } Future _solicitarPermisosNecesariosParaAlarma() async { try { final diag = await android.diagnostico(); _diagnostico = diag; if (!diag.puedeProgramarExactas) { await android.solicitarPermisoAlarmasExactas(); } if (!diag.notificacionesPermitidas) { await android.solicitarPermisoNotificaciones(); } if (!diag.puedeUsarPantallaCompleta) { await android.solicitarPermisoPantallaCompleta(); } if (!diag.ignoraOptimizacionBateria) { await _solicitarExencionBateriaUnaVez(); } } catch (e) { debugPrint('[PluriWave][alarmas] permisos android ERROR $e'); } } Future _solicitarExencionBateriaUnaVez() async { final prefs = _prefs ?? await SharedPreferences.getInstance(); if (prefs.getBool(_keyExencionBateriaSolicitada) ?? false) return; await android.solicitarExencionBateria(); await prefs.setBool(_keyExencionBateriaSolicitada, true); } Future _sincronizarTodas() async { debugPrint( '[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}', ); if (_alarmas.any((alarma) => alarma.activa)) { await _solicitarPermisosNecesariosParaAlarma(); } for (final alarma in _alarmas) { await android.programar(alarma); } } AlarmaMusical? _buscarAlarma(String id) { for (final alarma in _alarmas) { if (alarma.id == id) return alarma; } return null; } int _snoozeSeguro(int minutos) => minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5; void _aplicar(ConfiguracionAlarmas config) { _alarmas = config.alarmas; _vacaciones = config.vacaciones; _excepciones = config.excepciones; } void _activarRefresco() { _refresco?.cancel(); _refresco = Timer.periodic(const Duration(minutes: 1), (_) { refrescarProgramacion(); }); _vigilarAlarmasVencidas(); _vigilancia?.cancel(); _vigilancia = Timer.periodic(const Duration(seconds: 10), (_) { _vigilarAlarmasVencidas(); }); } void _vigilarAlarmasVencidas() { final ahora = DateTime.now(); _depurarEjecucionesEmitidas(ahora); for (final alarma in _alarmas) { final proxima = alarma.proximaProgramable; if (!alarma.activa || proxima == null) continue; if (proxima.isAfter(ahora)) continue; final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}'; final retraso = ahora.difference(proxima); if (retraso > _margenDisparoLocal) { _registrarEjecucionEmitida(key); debugPrint( '[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s', ); continue; } if (_registrarEjecucionEmitida(key)) { debugPrint( '[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}', ); _alarmasVencidasController.add(alarma); } } } /// Adds a `alarmId:millis` key and keeps the set bounded (S3-R6). /// Returns whether the key was newly added (fire-dedup contract). bool _registrarEjecucionEmitida(String key) { final agregada = _ejecucionesEmitidas.add(key); _depurarEjecucionesEmitidas(DateTime.now()); return agregada; } void _depurarEjecucionesEmitidas(DateTime ahora) { final limite = ahora.subtract(_retencionEjecucionesEmitidas).millisecondsSinceEpoch; _ejecucionesEmitidas.removeWhere((key) => _millisDeEjecucion(key) < limite); if (_ejecucionesEmitidas.length <= maxEjecucionesEmitidas) return; // Still over the cap: evict the oldest occurrences first. Pruned keys // cannot re-fire because occurrences beyond _margenDisparoLocal are // ignored by _vigilarAlarmasVencidas anyway. final ordenadas = _ejecucionesEmitidas.toList()..sort( (a, b) => _millisDeEjecucion(a).compareTo(_millisDeEjecucion(b)), ); _ejecucionesEmitidas.removeAll( ordenadas.take(_ejecucionesEmitidas.length - maxEjecucionesEmitidas), ); } int _millisDeEjecucion(String key) { final separador = key.lastIndexOf(':'); if (separador < 0) return 0; return int.tryParse(key.substring(separador + 1)) ?? 0; } @override void dispose() { _refresco?.cancel(); _vigilancia?.cancel(); _eventosNativosSub?.cancel(); _alarmasVencidasController.close(); super.dispose(); } }