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:
2026-06-11 15:33:30 +02:00
parent b5acf97ba4
commit f3e9487215
57 changed files with 4902 additions and 509 deletions
+101 -10
View File
@@ -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();
}