feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK) - Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed - Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels - Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV - Native fade-in volume ramp honoring fadeInSegundos when the app is killed - Request battery-optimization exemption once, tracked with a persisted asked-once flag - Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze - Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown - Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper) - Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0 - New alarm strings localized across all 13 locales - New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green) - SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
This commit is contained in:
+101
-10
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../servicios/servicio_alarmas.dart';
|
||||
@@ -10,9 +11,17 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
EstadoAlarmas({
|
||||
ServicioAlarmas? servicio,
|
||||
PuertoAlarmasAndroid? android,
|
||||
SharedPreferences? prefs,
|
||||
bool iniciarAutomaticamente = true,
|
||||
}) : servicio = servicio ?? ServicioAlarmas(),
|
||||
android = android ?? ServicioAlarmasAndroid() {
|
||||
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();
|
||||
}
|
||||
@@ -20,6 +29,8 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
|
||||
final ServicioAlarmas servicio;
|
||||
final PuertoAlarmasAndroid android;
|
||||
final SharedPreferences? _prefs;
|
||||
static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada';
|
||||
|
||||
List<AlarmaMusical> _alarmas = [];
|
||||
List<RangoVacaciones> _vacaciones = [];
|
||||
@@ -27,6 +38,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
DiagnosticoAlarmasAndroid? _diagnostico;
|
||||
Timer? _refresco;
|
||||
Timer? _vigilancia;
|
||||
StreamSubscription<EventoAlarmaAndroid>? _eventosNativosSub;
|
||||
final _alarmasVencidasController =
|
||||
StreamController<AlarmaMusical>.broadcast();
|
||||
final Set<String> _ejecucionesEmitidas = {};
|
||||
@@ -248,21 +260,89 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
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.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');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
|
||||
try {
|
||||
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
|
||||
if (ejecuciones.isEmpty) return;
|
||||
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}',
|
||||
);
|
||||
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 {
|
||||
@@ -278,11 +358,21 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
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}',
|
||||
@@ -351,6 +441,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
void dispose() {
|
||||
_refresco?.cancel();
|
||||
_vigilancia?.cancel();
|
||||
_eventosNativosSub?.cancel();
|
||||
_alarmasVencidasController.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user