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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user