The first pass read 'alarmaId'/'tipo' from the channel payload while the native side sends 'alarmId'/'type'/'atMillis' (AlarmScheduler.kt:1389). Every entry would have been dropped silently in production. The tests passed because the fake was seeded with the same guessed keys, so they confirmed the mistake instead of catching it. Decoding now goes through FalloProgramacionNativo.fromMap -- the single place native key names appear -- and the fixtures build through that same constructor.
914 lines
35 KiB
Dart
914 lines
35 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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
}
|