Reported: an alarm set for Monday 16:20 never rang, and the "next alarm"
banner showed a different alarm (the next morning's) instead. No
vacation range involved, both alarms active.
finalizarEjecucion anchored the completed occurrence to proximaEjecucion
with no check that it was the one actually ringing. On the native-fire
path the fire-time sync advances proximaEjecucion to the NEXT occurrence
before the user can reach the ring screen, so tapping Detener recorded a
FUTURE occurrence in ultimaEjecucionGestionada.
ServicioProgramacionAlarmas._esValida then rejects that occurrence for
real: a Monday-only alarm stopped today simply never rings next Monday,
and every sibling outranks it in the banner because its own
proximaEjecucion is a week out.
Reproduced at its purest in the second test: with nothing ringing at
09:01 on Monday, Detener pushed a 16:20 alarm to the FOLLOWING Monday.
posponerAlarma already had exactly this guard -- 9c7cf4e, "anchor snooze
to the ringing occurrence, never a future one", written after the same
failure showed up as a snooze armed a day out. It was applied to the
snooze path and never to the stop path, which sat ten lines below it in
the same file with the identical hazard.
Both paths now share one _ocurrenciaSonando helper so they cannot drift
apart again, and the reason lives in its doc comment rather than in a
comment on one of the two callers.
Also drops snoozeHasta from the stop path's candidate chain: a pending
snooze target is in the future by definition, and snoozeOrigen already
covers a ring that follows a snooze.
Tests: 1120 -> 1122.
932 lines
36 KiB
Dart
932 lines
36 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();
|
|
await cargarFallosNativos();
|
|
_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);
|
|
await _verificarRegistroNativo(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). 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<void> _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<void> _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<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();
|
|
}
|
|
|
|
/// 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) {
|
|
final ahora = servicio.ahora();
|
|
final limite = ahora.add(
|
|
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
|
);
|
|
DateTime? sonando(DateTime? candidata) =>
|
|
candidata != null && !candidata.isAfter(limite) ? candidata : null;
|
|
return sonando(alarma?.snoozeOrigen) ??
|
|
sonando(alarma?.proximaEjecucion) ??
|
|
sonando(alarma?.ultimaEjecucionGestionada) ??
|
|
ahora;
|
|
}
|
|
|
|
Future<void> 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();
|
|
}
|
|
|
|
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);
|
|
// 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<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();
|
|
}
|
|
|
|
/// 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<void> 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<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();
|
|
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<void> _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<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();
|
|
}
|
|
}
|