Files
pluriwave/lib/estado/estado_alarmas.dart
T
FreeTLab fd1b91fe9e fix(alarmas): wire scheduling failures into per-alarm exceptions
guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record
a scheduling failure via ServicioAlarmas.registrarFalloProgramacion
on catch and clear it on a successful (re)schedule, in addition to
the existing transient EstadoAlarmas.error string. This makes the
failure visible per-alarm via ultimaExcepcionPara instead of only a
generic app-wide message.

Also fixes _sincronizarTodas: a single alarm's android.programar
throw used to abort the whole loop, silently skipping every sibling
alarm scheduled AFTER it on that pass (including on every app launch,
via inicializar). Each alarm's outcome is now independent.
2026-07-31 21:08:21 +02:00

784 lines
29 KiB
Dart

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';
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<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
List<ExcepcionAlarma> _excepciones = [];
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
Timer? _vigilancia;
StreamSubscription<EventoAlarmaAndroid>? _eventosNativosSub;
final _alarmasVencidasController =
StreamController<AlarmaMusical>.broadcast();
final Set<String> _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<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
bool get cargando => _cargando;
String? get error => _error;
Stream<AlarmaMusical> 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<void> 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<void> guardarAlarma(AlarmaMusical alarma) async {
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);
} catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
Future<void> 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<void> 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<void> 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<void> _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<void> _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).
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
try {
final config = await servicio.limpiarFalloProgramacion(alarmaId);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
}
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
Future<void> 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<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
debugPrint(
'[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
);
final config = await servicio.guardarVacaciones(vacaciones);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
_error = null;
// The snooze anchors to the occurrence that is RINGING — never a future
// one. When the native fire works, the fire-time sync advances
// proximaEjecucion to the NEXT day before the user can even tap snooze,
// so anchoring to proximaEjecucion re-armed "posponer 3" a full day out
// (observed on-device: snooze armed for tomorrow 23:02). The ringing
// occurrence is the newest candidate not meaningfully in the future:
// snoozeOrigen (a re-snooze keeps the original anchor), then
// proximaEjecucion (watchdog path: still today's just-due occurrence),
// then ultimaEjecucionGestionada (native-fire path: the sync recorded
// the ringing occurrence there), then now.
final ahora = servicio.ahora();
final limite = ahora.add(
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
);
DateTime? sonando(DateTime? candidata) =>
candidata != null && !candidata.isAfter(limite) ? candidata : null;
final ejecucion =
sonando(alarma.snoozeOrigen) ??
sonando(alarma.proximaEjecucion) ??
sonando(alarma.ultimaEjecucionGestionada) ??
ahora;
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();
}
Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma,
int minutos,
DateTime ejecucion,
) async {
_error = null;
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);
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<void> finalizarEjecucion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
_error = null;
final alarma = _buscarAlarma(alarmaId);
final ejecucion =
alarma?.snoozeOrigen ??
alarma?.proximaEjecucion ??
alarma?.snoozeHasta ??
DateTime.now();
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<void> 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();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [..._vacaciones, rango];
await guardarVacaciones(nuevos);
}
Future<void> 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<void> 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<RangoVacaciones> 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<RangoVacaciones> 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 = <AlarmaMusical>[];
final noAfectadas = <AlarmaMusical>[];
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<void> 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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _solicitarExencionBateriaUnaVez() async {
final prefs = _prefs ?? await SharedPreferences.getInstance();
if (prefs.getBool(_keyExencionBateriaSolicitada) ?? false) return;
await android.solicitarExencionBateria();
await prefs.setBool(_keyExencionBateriaSolicitada, true);
}
Future<void> _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();
}
}