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:
@@ -123,9 +123,7 @@ class ServicioAlarmas {
|
||||
) async {
|
||||
final config = await cargar();
|
||||
final normalizadas =
|
||||
vacaciones
|
||||
.map((v) => v.normalizado())
|
||||
.toList()
|
||||
vacaciones.map((v) => v.normalizado()).toList()
|
||||
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
||||
final alarmas =
|
||||
config.alarmas
|
||||
@@ -147,9 +145,10 @@ class ServicioAlarmas {
|
||||
}) {
|
||||
final rango = RangoVacaciones(
|
||||
id: _uuid.v4(),
|
||||
nombre: (nombre == null || nombre.trim().isEmpty)
|
||||
? 'Vacaciones'
|
||||
: nombre.trim(),
|
||||
nombre:
|
||||
(nombre == null || nombre.trim().isEmpty)
|
||||
? 'Vacaciones'
|
||||
: nombre.trim(),
|
||||
inicio: inicio,
|
||||
fin: fin,
|
||||
);
|
||||
@@ -259,7 +258,17 @@ class ServicioAlarmas {
|
||||
DateTime ejecucion,
|
||||
int minutos,
|
||||
) async {
|
||||
final snoozeHasta = _programacion.calcularSnooze(_reloj(), minutos);
|
||||
// Unified snooze anchor (Design 2.2): occurrence + minutes, clamped to
|
||||
// now + minutes when the target already passed. Matches the native
|
||||
// AlarmScheduler.snooze/postponeNext semantics so both layers always
|
||||
// land on the same re-fire time.
|
||||
final seguros = minutos.clamp(1, 120);
|
||||
final objetivo = ejecucion.add(Duration(minutes: seguros));
|
||||
final ahora = _reloj();
|
||||
final snoozeHasta =
|
||||
objetivo.isAfter(ahora)
|
||||
? objetivo
|
||||
: ahora.add(Duration(minutes: seguros));
|
||||
return posponerEjecucionHasta(alarmaId, ejecucion, snoozeHasta);
|
||||
}
|
||||
|
||||
@@ -381,8 +390,12 @@ class ServicioAlarmas {
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
) {
|
||||
final ahora = _reloj();
|
||||
// S2-R5: a disabled alarm must not keep a pending snooze; clearing it
|
||||
// here guarantees the snoozed occurrence dies with the alarm.
|
||||
final snoozeActivo =
|
||||
alarma.snoozeHasta != null && alarma.snoozeHasta!.isAfter(ahora);
|
||||
alarma.activa &&
|
||||
alarma.snoozeHasta != null &&
|
||||
alarma.snoozeHasta!.isAfter(ahora);
|
||||
final proxima = _programacion.calcularProxima(
|
||||
alarma: alarma,
|
||||
desde: ahora,
|
||||
|
||||
@@ -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