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'; import '../servicios/servicio_programacion_alarmas.dart'; /// Distinct "limit reached" signal (Design ADR-5, freemium-gating spec /// "Alarm Count Cap At 5"): kept SEPARATE from [EstadoAlarmas.error], which /// stays reserved for native scheduling failures — overloading it would /// surface a free-tier limit as a scheduling failure in `app.dart`'s global /// snackbar path. enum ResultadoGuardarAlarma { guardada, limiteAlcanzado } class EstadoAlarmas extends ChangeNotifier { EstadoAlarmas({ ServicioAlarmas? servicio, PuertoAlarmasAndroid? android, SharedPreferences? prefs, bool iniciarAutomaticamente = true, // iap-freemium-unlock (Design ADR-3): entitlement query, mirroring // `EstadoGrabacion`'s `emisoraActual` callback-injection shape rather // than a direct `EstadoEntitlement` dependency (this notifier must stay // constructible with zero widget-tree/Provider context). REQUIRED on // purpose: an optional parameter with any default lets a forgotten // wiring compile and silently pick a tier, and no test can catch that. // Callers must state the entitlement source explicitly. required bool Function() esPremium, }) : servicio = servicio ?? ServicioAlarmas(prefs: prefs), android = android ?? ServicioAlarmasAndroid(), _prefs = prefs, _esPremium = esPremium { // 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; final bool Function() _esPremium; static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada'; /// Free-tier alarm cap (freemium-gating spec "Alarm Count Cap At 5"). static const maxAlarmasFree = 5; 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; /// Last alarm id recorded as MISSED (RES-1): lets the ringing screen /// detect an external end-of-ring for its own alarm and reconcile. String? ultimaAlarmaPerdidaId; 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(); await cargarFallosNativos(); _activarRefresco(); } catch (e) { _error = 'No se pudieron cargar las alarmas: $e'; debugPrint('[PluriWave][alarmas] inicializar ERROR $e'); } finally { _cargando = false; notifyListeners(); } } /// Pure query (freemium-gating spec "Alarm Count Cap At 5"): whether a NEW /// alarm may be created right now. Counts ALL alarms regardless of /// `activa` (Spec "6th alarm creation is blocked" — "any enabled state"). /// Always `true` for premium (no cap). Editing an existing id is never /// subject to this — see [guardarAlarma]'s own new-vs-edit check. bool puedeCrearAlarma() => _esPremium() || _alarmas.length < maxAlarmasFree; Future guardarAlarma(AlarmaMusical alarma) async { // Gate BEFORE any native scheduling attempt (freemium-gating spec "6th // alarm creation is blocked": "no native scheduling is attempted"). // Editing an alarm that already exists (by id) is NEVER capped — only // genuinely NEW creation counts against the limit (Spec "Editing an // existing alarm is unaffected", grandfathering). final esAlarmaNueva = !_alarmas.any((a) => a.id == alarma.id); if (esAlarmaNueva && !puedeCrearAlarma()) { debugPrint( '[PluriWave][alarmas] guardar bloqueado por limite free id=${alarma.id}', ); return ResultadoGuardarAlarma.limiteAlcanzado; } debugPrint( '[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}', ); // Mutation-while-ringing stop guard (SS-1a/SS-1b): fires BEFORE the save // persists so an edit/toggle-off of the currently-ringing alarm always // silences it first. await _detenerSiEstaSonando(alarma.id); 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); await _limpiarFalloProgramacion(guardada.id); await _verificarRegistroNativo(guardada.id); } catch (e) { _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; await _registrarFalloProgramacion(alarma.id); } notifyListeners(); return ResultadoGuardarAlarma.guardada; } 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); // Deleting the ringing alarm stops audio (SS-1c, regression lock): the // centralized guard runs before cancelar, same as guardarAlarma. await _detenerSiEstaSonando(id); await android.cancelar(id); notifyListeners(); } /// Centralized mutation-while-ringing stop guard (Decision 5): every /// mutation of the currently-ringing alarm routes through this ONE check /// instead of per-call-site logic, so a mutation of a DIFFERENT (non- /// ringing) alarm never touches the live ring (SS-1d). Future _detenerSiEstaSonando(String id) async { try { final sonando = await android.alarmaSonandoId(); if (sonando == id) { await android.detenerSonidoActivo(); } } catch (e) { debugPrint('[PluriWave][alarmas] detenerSiEstaSonando ERROR $e'); // Fail-toward-silence (Finding 2, eliminarAlarma regression): a failed // query must not silently skip the stop when the alarm might genuinely // be ringing. Fall back to the id-scoped legacy stop (the native side // no-ops safely on a mismatch) inside its own try/catch so this outer // flow (guardarAlarma/eliminarAlarma) always proceeds regardless. try { await android.detenerSonidoNativo(id); } catch (fallbackError) { debugPrint( '[PluriWave][alarmas] detenerSiEstaSonando fallback ERROR $fallbackError', ); } } } /// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos- /// silenciosos): before this, a failed `android.programar` call only set /// the transient, alarm-agnostic [_error] string — the alarms list had no /// way to mark the SPECIFIC card affected, so a failed alarm rendered /// exactly like a working one. Never rethrows: a failure recording its own /// failure must not mask the ORIGINAL scheduling error already captured in /// [_error]. Future _registrarFalloProgramacion( String alarmaId, { String tipo = ExcepcionAlarma.tipoFalloProgramacion, }) async { try { final alarma = _buscarAlarma(alarmaId); final ejecucion = alarma?.proximaProgramable ?? servicio.ahora(); final config = await servicio.registrarFalloProgramacion( alarmaId, ejecucion, tipo, ); _aplicar(config); } catch (e) { debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e'); } } /// Clears a previously recorded scheduling failure once a later attempt /// for the same alarm succeeds (D5-style recovery, mirroring how [_error] /// itself already clears on a successful retry). Type-scoped: a /// successful `android.programar` call only proves the MAIN alarm /// registration (and, transitively, that any stale post-boot reschedule /// failure no longer applies) -- it says nothing about the pre-notice or /// foreground-service subsystems, so those are left untouched here. Future _limpiarFalloProgramacion(String alarmaId) async { try { var config = await servicio.limpiarFalloProgramacion( alarmaId, ExcepcionAlarma.tipoFalloProgramacion, ); _aplicar(config); config = await servicio.limpiarFalloProgramacion( alarmaId, ExcepcionAlarma.tipoFalloReprogramacionArranque, ); _aplicar(config); } catch (e) { debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e'); } } /// Verifies the OS genuinely registered [alarmaId] after a successful /// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a /// scheduling call that returns without throwing is not proof enough by /// itself -- this cross-check against the native pending-alarm count is /// exactly what would have caught the reported "alarm never rings, no /// exception anywhere" case. Compares a FRESH native count against how /// many alarms Dart believes are currently active-with-a-next-run; a /// native count that falls short is recorded as a failure for the alarm /// the user just interacted with. Never overrides an already-caught /// programar() exception (this only runs on ITS success path). Future _verificarRegistroNativo(String alarmaId) async { try { final alarma = _buscarAlarma(alarmaId); if (alarma == null || !alarma.activa || alarma.proximaProgramable == null) { return; } final diag = await android.diagnostico(); _diagnostico = diag; final esperadas = _alarmas .where((a) => a.activa && a.proximaProgramable != null) .length; if (diag.alarmasNativasPendientes < esperadas) { _error = 'Alarma guardada, pero el sistema no confirma que quedó registrada.'; await _registrarFalloProgramacion(alarmaId); } } catch (e) { debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e'); } } 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(); } /// The occurrence that is ACTUALLY ringing right now — the anchor both /// ring-screen actions (Posponer and Detener) must close. /// /// It is NEVER a future occurrence. When the native fire works, the /// fire-time sync advances `proximaEjecucion` to the next one before the /// user can even reach the ring screen, so taking `proximaEjecucion` /// unguarded closes an occurrence that has not happened yet. For snooze /// that showed up as "posponer 3" arming a full day out (observed /// on-device: tomorrow 23:02). For stop it was worse and silent: the /// future occurrence was recorded in `ultimaEjecucionGestionada`, which /// `ServicioProgramacionAlarmas._esValida` then rejects for real — so a /// Monday-only alarm stopped today simply never rang next Monday, and /// every sibling alarm outranked it in the "next alarm" banner. /// /// The candidates, newest first, each gated on "not meaningfully in the /// future": [AlarmaMusical.snoozeOrigen] (a re-snooze keeps the original /// anchor), then [AlarmaMusical.proximaEjecucion] (watchdog path: still /// today's just-due occurrence), then /// [AlarmaMusical.ultimaEjecucionGestionada] (native-fire path: the sync /// recorded the ringing occurrence there), then now. /// /// ONE helper for BOTH callers on purpose. This guard was written for /// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten /// lines below with the identical hazard and no guard, and it stayed that /// way until a user lost a whole week of alarms. Do not re-inline it. DateTime _ocurrenciaSonando(AlarmaMusical? alarma) => _ocurrenciaValida(alarma); /// How far ahead the PRE-NOTICE notification's occurrence may legitimately /// sit: it is armed exactly this far before the alarm, so between the /// reminder appearing and the user tapping it, the occurrence has not /// happened yet and rejecting it would be wrong. /// /// Mirrors `AlarmScheduler.PRE_NOTICE_MILLIS` (30 min). Both sides must /// agree or one of them starts discarding perfectly good anchors. static const ventanaPreaviso = Duration(minutes: 30); /// [_ocurrenciaSonando] generalized with a forward allowance, and with an /// externally-supplied [propuesta] taking priority when it survives the /// same check. /// /// [propuesta] is what the NATIVE side reported as the occurrence its /// notification was about. It is trusted first — it is better evidence than /// anything reconstructed here — but only after being validated, because it /// can arrive as a fallback the caller invented (`app.dart` substitutes /// `alarma.proximaEjecucion` when the native event carries no occurrence, /// and that field may already point at tomorrow). DateTime _ocurrenciaValida( AlarmaMusical? alarma, { DateTime? propuesta, Duration margen = Duration.zero, }) { final ahora = servicio.ahora(); final limite = ahora.add( margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente, ); DateTime? sonando(DateTime? candidata) => candidata != null && !candidata.isAfter(limite) ? candidata : null; return sonando(propuesta) ?? sonando(alarma?.snoozeOrigen) ?? sonando(alarma?.proximaEjecucion) ?? sonando(alarma?.ultimaEjecucionGestionada) ?? ahora; } Future posponerAlarma(AlarmaMusical alarma, int minutos) async { _error = null; final ejecucion = _ocurrenciaSonando(alarma); 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); try { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } /// "Posponer" on the PRE-NOTICE notification. /// /// Reported on-device: this left the alarm snoozed for 1400+ minutes — a /// whole day — instead of the configured few. The native lane got its guard /// in 7054a4c, but Dart runs AFTERWARDS on this path (the receiver's /// `postponeNext` fires, then `startActivity`, then this) and persists + /// reschedules, so whatever it computes is the value that survives. It was /// the last snooze path in the codebase with NO occurrence guard at all: /// it took [ejecucion] on faith and turned it straight into the next alarm. /// /// And [ejecucion] is not trustworthy: `app.dart` falls back to /// `alarma.proximaEjecucion` whenever the native event carries no /// occurrence, and that field can already point at tomorrow. /// /// Validated through [_ocurrenciaValida] with a [ventanaPreaviso] /// allowance — unlike the ringing-screen paths this occurrence legitimately /// has NOT arrived yet, which is exactly why `_ocurrenciaSonando` could not /// simply be reused here. Future posponerProximaDesdePreaviso( AlarmaMusical alarma, int minutos, DateTime ejecucion, ) async { _error = null; final seguros = _snoozeSeguro(minutos); final ocurrencia = _ocurrenciaValida( alarma, propuesta: ejecucion, margen: ventanaPreaviso, ); final snoozeHasta = ocurrencia.add(Duration(minutes: seguros)); debugPrint( '[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros propuesta=${ejecucion.toIso8601String()} ocurrencia=${ocurrencia.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}', ); await android.ocultarNotificacionAlarma(alarma.id); final config = await servicio.posponerEjecucionHasta( alarma.id, // The VALIDATED occurrence, not the raw parameter: this becomes both // `snoozeOrigen` and `ultimaEjecucionGestionada`, so passing the // unchecked value here would poison the very state a9da855/0430059 // exist to keep clean. ocurrencia, snoozeHasta, ); _aplicar(config); final actualizada = _buscarAlarma(alarma.id); try { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } Future finalizarEjecucion(String alarmaId) async { debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId'); _error = null; final alarma = _buscarAlarma(alarmaId); // Same anchor as posponerAlarma, through the same helper: closing a // future occurrence here marks it handled, and _esValida then skips it // for real -- the alarm silently never rings that day. See // [_ocurrenciaSonando]. final ejecucion = _ocurrenciaSonando(alarma); await android.ocultarNotificacionAlarma(alarmaId); // Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the // id-agnostic fail-safe stop directly (it always targets whatever is // ringing). `detenido` reflects the VERIFIED native teardown state // (activeRingingId cleared same-process after a synchronous stop), not a // literal dispatch acknowledgement, so a genuine failure is never // swallowed. final resultado = await android.detenerSonidoActivo(); if (!resultado.detenido) { _error = 'No se pudo confirmar que la alarma dejo de sonar.'; } final config = await servicio.completarEjecucion(alarmaId, ejecucion); _aplicar(config); await _sincronizarTodas(); notifyListeners(); } /// Retryable force-stop affordance (SS-3b): re-invokes the same fail-safe /// stop; success clears the recorded failure, another failure keeps it. Future forzarDetencion(String alarmaId) async { debugPrint('[PluriWave][alarmas] forzar detencion id=$alarmaId'); final resultado = await android.detenerSonidoActivo(); _error = resultado.detenido ? null : 'No se pudo detener la alarma. Intentalo de nuevo.'; notifyListeners(); } /// Full premium gate (freemium-gating spec "Gated Feature Set (Exactly /// 4)" — alarm vacations, unlike the alarm cap above, are gated entirely, /// not counted): returns `false` without persisting anything when the /// caller is free tier. Future crearRangoVacaciones(RangoVacaciones rango) async { if (!_esPremium()) { debugPrint( '[PluriWave][alarmas] crear vacaciones bloqueado (free) id=${rango.id}', ); return false; } final nuevos = [..._vacaciones, rango]; await guardarVacaciones(nuevos); return true; } Future eliminarRangoVacaciones(String id) async { final nuevos = _vacaciones.where((v) => v.id != id).toList(); await guardarVacaciones(nuevos); } /// Issue 1 (feedback-pruebas): replaces the range with the same [id] in /// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones` /// were missing before this fix, leaving no way to fix a mistake in an /// already-saved range (including the currently ACTIVE one, since a /// freshly created range starts active immediately). Future editarRangoVacaciones(RangoVacaciones rango) async { final nuevos = [ for (final actual in _vacaciones) if (actual.id == rango.id) rango else actual, ]; await guardarVacaciones(nuevos); } // ── Vacation queries (design ADR-6, WU9) ────────────────────────────── // Four PURE queries: none writes, none reschedules, none touches the // native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method // takes `{DateTime? ahora}` (clock injection) defaulting to // `DateTime.now()` so tests can pass a fixed instant. /// Currently-active vacation range, if today falls within one. /// Delegates to the existing `RangoVacaciones.contiene(fecha)` — which /// already handles the `activo` flag and day granularity — rather than /// reimplementing date math (a second implementation is a second set of /// off-by-one bugs). Callers derive "days remaining" themselves from the /// returned range's `finDia`, the same way WU8's summary row already /// does. RangoVacaciones? rangoVacacionesActivo({DateTime? ahora}) { final fecha = ahora ?? DateTime.now(); for (final rango in _vacaciones) { if (rango.contiene(fecha)) return rango; } return null; } /// Ranges that have not yet started (`inicio > hoy`), soonest-first. List vacacionesProximas({DateTime? ahora}) { final fecha = ahora ?? DateTime.now(); final hoy = DateTime(fecha.year, fecha.month, fecha.day); return _vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList() ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); } /// Ranges whose end date has already passed (`fin < hoy`), most-recently- /// ended first. List vacacionesPasadas({DateTime? ahora}) { final fecha = ahora ?? DateTime.now(); final hoy = DateTime(fecha.year, fecha.month, fecha.day); return _vacaciones.where((rango) => rango.finDia.isBefore(hoy)).toList() ..sort((a, b) => b.finDia.compareTo(a.finDia)); } /// Per-alarm pause impact for [rango]. Mirrors /// `ServicioProgramacionAlarmas`'s own pause predicate EXACTLY /// (`servicio_programacion_alarmas.dart`: /// `!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)`) /// — if these two ever diverge, the Vacaciones screen lies about which /// alarms are paused. [rango] is accepted for API symmetry with the /// other 3 queries above; the predicate itself needs no dates because it /// only makes sense to call this for a range that IS currently active — /// any alarm actually paused by it already has `sonarEnVacaciones == /// false`, which is exactly what the scheduler itself would have used to /// skip that alarm's candidate occurrence. ImpactoVacaciones impactoDeRango(RangoVacaciones rango) { final pausadas = []; final noAfectadas = []; for (final alarma in _alarmas) { if (!alarma.activa) continue; if (alarma.sonarEnVacaciones) { noAfectadas.add(alarma); } else { pausadas.add(alarma); } } return ImpactoVacaciones(pausadas: pausadas, noAfectadas: noAfectadas); } 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(); } /// Drains the failures the NATIVE side recorded on its own and turns each /// into a per-alarm exception, so the card can mark it. /// /// These three paths used to log to logcat and stop there: a pre-notice /// that could not be armed, a refused foreground-service start when the /// alarm should have rung, and a per-alarm reschedule that failed after a /// reboot. None of them run inside a Dart call, so nothing on this side /// ever learned they happened — an alarm could sit switched on in the /// list having never reached the OS. Reading them at startup is what /// makes the reported "as if there were no alarm" visible. /// /// Deliberately tolerant: a failed read is logged and swallowed, never /// surfaced as an alarm error, because a diagnostics gap must not look /// like a scheduling problem. Future cargarFallosNativos() async { try { final fallos = await android.fallosNativosProgramacion(); for (final fallo in fallos) { await _registrarFalloProgramacion(fallo.alarmaId, tipo: fallo.tipo); } if (fallos.isNotEmpty) { debugPrint( '[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}', ); } } catch (e) { debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e'); } } /// 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.accionSnoozeCancelled) { await _registrarCancelacionSnoozeNativa(evento); return; } if (evento.accion == EventoAlarmaAndroid.accionMissed) { await _registrarEjecucionPerdida(evento); return; } 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'); } } /// Mirrors a native snooze cancellation ("Detener" on the countdown /// notification). The native scheduler already advanced to the next normal /// occurrence, so this only clears the snooze in the canonical config and /// MUST NOT call android.programar again (would double-schedule). Future _registrarCancelacionSnoozeNativa( EventoAlarmaAndroid evento, ) async { if (evento.alarmaId.isEmpty) return; final origen = evento.occurrenceAtMillis > 0 ? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis) : DateTime.now(); debugPrint( '[PluriWave][alarmas] snooze cancelado nativo id=${evento.alarmaId} origen=${origen.toIso8601String()}', ); try { final config = await servicio.completarEjecucion(evento.alarmaId, origen); _aplicar(config); notifyListeners(); } catch (e) { debugPrint('[PluriWave][alarmas] cancelar snooze nativo ERROR $e'); } } /// Records a native auto-silence (MISSED) transition (Phase 6): the native /// scheduler already rearmed the next occurrence (repeating) or left it /// disabled (one-shot) at fire time, so this only marks the occurrence /// handled -- it MUST NOT call android.programar again. Future _registrarEjecucionPerdida(EventoAlarmaAndroid evento) async { if (evento.alarmaId.isEmpty) return; final origen = evento.occurrenceAtMillis > 0 ? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis) : DateTime.now(); debugPrint( '[PluriWave][alarmas] ejecucion perdida id=${evento.alarmaId} origen=${origen.toIso8601String()}', ); try { final config = await servicio.completarEjecucion(evento.alarmaId, origen); _aplicar(config); ultimaAlarmaPerdidaId = evento.alarmaId; notifyListeners(); } catch (e) { debugPrint('[PluriWave][alarmas] ejecucion perdida 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(); await _importarFallosProgramacionNativos(); } /// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports /// scheduling-reliability failures the NATIVE side recorded on its own -- /// a pre-notice `SecurityException`, a refused foreground-service start, /// or a per-alarm reschedule failure after boot/unlock -- none of which /// ever go through a Dart method-channel call that could throw. Without /// this sync, these three failures stayed invisible forever (only /// logcat), even after this app-launch fix reads them. Future _importarFallosProgramacionNativos() async { try { final fallos = await android.obtenerFallosProgramacionNativos(); final reportadoPorAlarma = { for (final fallo in fallos) fallo.alarmaId: fallo, }; // Reconcile stale copies: the native side clears its OWN record the // next time that specific subsystem succeeds (pre-notice/foreground- // service), so an alarm previously imported with one of those tipos // that is no longer reported here means it already recovered -- // without this, the card would keep showing a problem that fixed // itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque` // are NOT reconciled here -- those already clear on the Dart side's // own successful `android.programar` calls. for (final alarma in _alarmas) { final actual = ultimaExcepcionPara(alarma.id); final esTipoReconciliable = actual != null && (actual.tipo == ExcepcionAlarma.tipoFalloPreaviso || actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido); if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) { final config = await servicio.limpiarFalloProgramacion( alarma.id, actual.tipo, ); _aplicar(config); } } for (final fallo in fallos) { final config = await servicio.registrarFalloProgramacion( fallo.alarmaId, fallo.ocurridoEn, fallo.tipo, ); _aplicar(config); } if (fallos.isNotEmpty) { debugPrint( '[PluriWave][alarmas] fallos nativos importados count=${fallos.length}', ); } } catch (e) { debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e'); } } /// 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(); } // Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a // SINGLE alarm's `programar` throw aborted the whole loop, so every // sibling AFTER the failing one in `_alarmas` silently never reached // `android.programar` on this pass -- on a fresh launch (`inicializar`) // that meant some active alarms were never (re)armed with the OS at all, // with nothing to show for it beyond a generic load error. Each alarm // now gets its own outcome recorded, and one failure never blocks its // siblings. for (final alarma in _alarmas) { try { await android.programar(alarma); await _limpiarFalloProgramacion(alarma.id); } catch (e) { debugPrint( '[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e', ); await _registrarFalloProgramacion(alarma.id); } } } 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(); } }