posponerAlarma() and posponerProximaDesdePreaviso() called the native scheduler with no error handling, unlike guardarAlarma(). When the native call failed (e.g. revoked exact-alarm permission), the exception escaped before notifyListeners() ran, leaving the alarm list stuck on stale data with no real alarm scheduled and no snooze countdown notification. Both methods now mirror guardarAlarma()'s pattern: permission pre-check, try/catch into _error, and an unconditional notifyListeners() so the UI always reflects the outcome. Failures surface via SnackBar in the ringing screen and in app.dart's postpone-next handler.
540 lines
18 KiB
Dart
540 lines
18 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';
|
|
|
|
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;
|
|
|
|
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}',
|
|
);
|
|
final config = await servicio.guardarAlarma(alarma);
|
|
_aplicar(config);
|
|
try {
|
|
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id);
|
|
await _solicitarPermisosNecesariosParaAlarma();
|
|
debugPrint(
|
|
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
|
|
);
|
|
await android.programar(guardada);
|
|
} catch (e) {
|
|
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<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);
|
|
await android.detenerSonidoNativo(id);
|
|
await android.cancelar(id);
|
|
notifyListeners();
|
|
}
|
|
|
|
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;
|
|
final ejecucion =
|
|
alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now();
|
|
debugPrint(
|
|
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
|
|
);
|
|
await android.ocultarNotificacionAlarma(alarma.id);
|
|
final config = await servicio.posponerEjecucion(
|
|
alarma.id,
|
|
ejecucion,
|
|
minutos,
|
|
);
|
|
_aplicar(config);
|
|
final actualizada = _buscarAlarma(alarma.id);
|
|
try {
|
|
if (actualizada != null) {
|
|
await _solicitarPermisosNecesariosParaAlarma();
|
|
await android.programar(actualizada);
|
|
}
|
|
} catch (e) {
|
|
_error =
|
|
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
|
|
}
|
|
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);
|
|
}
|
|
} catch (e) {
|
|
_error =
|
|
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> finalizarEjecucion(String alarmaId) async {
|
|
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
|
|
final alarma = _buscarAlarma(alarmaId);
|
|
final ejecucion =
|
|
alarma?.snoozeOrigen ??
|
|
alarma?.proximaEjecucion ??
|
|
alarma?.snoozeHasta ??
|
|
DateTime.now();
|
|
await android.ocultarNotificacionAlarma(alarmaId);
|
|
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
|
|
_aplicar(config);
|
|
await _sincronizarTodas();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<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);
|
|
}
|
|
|
|
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.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');
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
for (final alarma in _alarmas) {
|
|
await android.programar(alarma);
|
|
}
|
|
}
|
|
|
|
AlarmaMusical? _buscarAlarma(String id) {
|
|
for (final alarma in _alarmas) {
|
|
if (alarma.id == id) return alarma;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
int _snoozeSeguro(int minutos) =>
|
|
minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
|
|
|
|
void _aplicar(ConfiguracionAlarmas config) {
|
|
_alarmas = config.alarmas;
|
|
_vacaciones = config.vacaciones;
|
|
_excepciones = config.excepciones;
|
|
}
|
|
|
|
void _activarRefresco() {
|
|
_refresco?.cancel();
|
|
_refresco = Timer.periodic(const Duration(minutes: 1), (_) {
|
|
refrescarProgramacion();
|
|
});
|
|
_vigilarAlarmasVencidas();
|
|
_vigilancia?.cancel();
|
|
_vigilancia = Timer.periodic(const Duration(seconds: 10), (_) {
|
|
_vigilarAlarmasVencidas();
|
|
});
|
|
}
|
|
|
|
void _vigilarAlarmasVencidas() {
|
|
final ahora = DateTime.now();
|
|
_depurarEjecucionesEmitidas(ahora);
|
|
for (final alarma in _alarmas) {
|
|
final proxima = alarma.proximaProgramable;
|
|
if (!alarma.activa || proxima == null) continue;
|
|
if (proxima.isAfter(ahora)) continue;
|
|
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
|
|
final retraso = ahora.difference(proxima);
|
|
if (retraso > _margenDisparoLocal) {
|
|
_registrarEjecucionEmitida(key);
|
|
debugPrint(
|
|
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
|
|
);
|
|
continue;
|
|
}
|
|
if (_registrarEjecucionEmitida(key)) {
|
|
debugPrint(
|
|
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
|
);
|
|
_alarmasVencidasController.add(alarma);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adds a `alarmId:millis` key and keeps the set bounded (S3-R6).
|
|
/// Returns whether the key was newly added (fire-dedup contract).
|
|
bool _registrarEjecucionEmitida(String key) {
|
|
final agregada = _ejecucionesEmitidas.add(key);
|
|
_depurarEjecucionesEmitidas(DateTime.now());
|
|
return agregada;
|
|
}
|
|
|
|
void _depurarEjecucionesEmitidas(DateTime ahora) {
|
|
final limite =
|
|
ahora.subtract(_retencionEjecucionesEmitidas).millisecondsSinceEpoch;
|
|
_ejecucionesEmitidas.removeWhere((key) => _millisDeEjecucion(key) < limite);
|
|
if (_ejecucionesEmitidas.length <= maxEjecucionesEmitidas) return;
|
|
// Still over the cap: evict the oldest occurrences first. Pruned keys
|
|
// cannot re-fire because occurrences beyond _margenDisparoLocal are
|
|
// ignored by _vigilarAlarmasVencidas anyway.
|
|
final ordenadas =
|
|
_ejecucionesEmitidas.toList()..sort(
|
|
(a, b) => _millisDeEjecucion(a).compareTo(_millisDeEjecucion(b)),
|
|
);
|
|
_ejecucionesEmitidas.removeAll(
|
|
ordenadas.take(_ejecucionesEmitidas.length - maxEjecucionesEmitidas),
|
|
);
|
|
}
|
|
|
|
int _millisDeEjecucion(String key) {
|
|
final separador = key.lastIndexOf(':');
|
|
if (separador < 0) return 0;
|
|
return int.tryParse(key.substring(separador + 1)) ?? 0;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_refresco?.cancel();
|
|
_vigilancia?.cancel();
|
|
_eventosNativosSub?.cancel();
|
|
_alarmasVencidasController.close();
|
|
super.dispose();
|
|
}
|
|
}
|