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
+71 -3
View File
@@ -16,14 +16,20 @@ class EventoAlarmaAndroid {
this.triggerAtMillis = 0,
this.occurrenceAtMillis = 0,
this.snoozeMinutes = 5,
this.snoozeUntilMillis = 0,
});
/// Action reported when the native service snoozed an alarm by itself
/// (notification "Posponer" while the app may be backgrounded/killed).
static const accionSnoozed = 'snoozed';
final String alarmaId;
final String titulo;
final String accion;
final int triggerAtMillis;
final int occurrenceAtMillis;
final int snoozeMinutes;
final int snoozeUntilMillis;
factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) {
return EventoAlarmaAndroid(
@@ -33,6 +39,34 @@ class EventoAlarmaAndroid {
triggerAtMillis: (map['triggerAtMillis'] as num?)?.toInt() ?? 0,
occurrenceAtMillis: (map['occurrenceAtMillis'] as num?)?.toInt() ?? 0,
snoozeMinutes: (map['snoozeMinutes'] as num?)?.toInt() ?? 5,
snoozeUntilMillis: (map['snoozeUntilMillis'] as num?)?.toInt() ?? 0,
);
}
}
/// Active native snooze persisted by `AlarmScheduler` (Kotlin). Used on cold
/// start so Flutter (single source of truth) can import snoozes performed
/// while the engine was dead.
class EstadoSnoozeNativo {
const EstadoSnoozeNativo({
required this.alarmaId,
required this.snoozeHasta,
required this.snoozeOrigen,
});
final String alarmaId;
final DateTime snoozeHasta;
final DateTime snoozeOrigen;
factory EstadoSnoozeNativo.fromMap(Map<Object?, Object?> map) {
return EstadoSnoozeNativo(
alarmaId: map['alarmId'] as String? ?? '',
snoozeHasta: DateTime.fromMillisecondsSinceEpoch(
(map['snoozeUntilMillis'] as num?)?.toInt() ?? 0,
),
snoozeOrigen: DateTime.fromMillisecondsSinceEpoch(
(map['snoozeOriginMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
@@ -60,8 +94,7 @@ class DiagnosticoAlarmasAndroid {
return DiagnosticoAlarmasAndroid(
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true,
puedeUsarPantallaCompleta:
map['canUseFullScreenIntent'] as bool? ?? true,
puedeUsarPantallaCompleta: map['canUseFullScreenIntent'] as bool? ?? true,
ignoraOptimizacionBateria:
map['isIgnoringBatteryOptimizations'] as bool? ?? true,
alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0,
@@ -100,10 +133,12 @@ abstract class PuertoAlarmasAndroid {
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
Future<void> confirmarAudioFlutter(String alarmaId);
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
@@ -151,7 +186,9 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
alarma.snoozeHasta == null
? proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch
? proxima
.subtract(const Duration(minutes: 30))
.millisecondsSinceEpoch
: 0,
'hour': alarma.hora,
'minute': alarma.minuto,
@@ -169,8 +206,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
? null
: localizedStationName(_textos, alarma.emisora!.nombre),
'stationUrl': alarma.emisora?.url,
'fallbackStationName':
alarma.emisoraFallback == null
? null
: localizedStationName(_textos, alarma.emisoraFallback!.nombre),
'fallbackStationUrl': alarma.emisoraFallback?.url,
'fallbackSound': alarma.sonidoInterno.name,
'volume': alarma.volumen,
'fadeInSegundos': alarma.fadeInSegundos,
});
if (programada != true) {
throw StateError(_textos.androidExactAlarmScheduleError);
@@ -217,6 +260,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
return abierto ?? false;
}
@override
Future<bool> solicitarExencionBateria() async {
final abierto = await _channel.invokeMethod<bool>(
'requestIgnoreBatteryOptimizations',
);
return abierto ?? false;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
@@ -261,6 +312,23 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
.toList();
}
@override
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSnoozeState',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(EstadoSnoozeNativo.fromMap)
.where(
(estado) =>
estado.alarmaId.isNotEmpty &&
estado.snoozeHasta.millisecondsSinceEpoch > 0,
)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);