fix(alarmas,auto): guard the last unguarded snooze path, surface car progress

Continuation of 7054a4c: the native anchor guard alone did not fix the
reported ~1444-minute snooze, because Dart runs AFTERWARDS on the
pre-notice path and had no guard at all.

1. Snooze from the pre-notice notification, root cause.

app.dart dispatches AFTER the receiver's postponeNext already ran and
after startActivity, and EstadoAlarmas.posponerProximaDesdePreaviso took
whatever occurrence it was handed on faith, then persisted and
rescheduled from it -- the last snooze path in the codebase with no
occurrence guard. The occurrence itself is not trustworthy either:
app.dart falls back to alarma.proximaEjecucion when the native event
carries none, and that field can already point at tomorrow.

_ocurrenciaSonando is generalized into _ocurrenciaValida with a caller-
supplied forward allowance and an externally-proposed occurrence that
still has to survive the same check. The pre-notice path gets a
ventanaPreaviso (30 min, matching AlarmScheduler.PRE_NOTICE_MILLIS) --
unlike the ringing-screen guard, this occurrence legitimately has not
happened yet, which is exactly why the existing helper could not just be
reused here.

Also heals state already poisoned by the missing guard: a snoozeHasta
parked past a 3-hour ceiling (posponerEjecucion clamps to 120 minutes,
so anything beyond that is corruption, not a long real snooze) is
dropped on recalculation. Without it, an alarm poisoned on a build
before this fix keeps reporting tomorrow after updating, and the user
reasonably concludes nothing changed.

2. Android Auto: no progress bar or time labels on a local track.

updatePosition was never set anywhere in the handler, so it sat at its
Duration.zero default while copyWith refreshed updateTime to now on every
push -- the car was told "position 0, as of right now" on every event, a
bar pinned at the start regardless of what was actually playing. Now set
from _player.position on both the player-state and buffered-position
listeners (the latter ticks ~2/s, which is what keeps the car's bar
smooth between player-state events). Also stream the MediaItem's
duration once the source reports it -- Auto draws no bar at all without
one, and radio streams correctly keep reporting none (live audio has no
length).

3. Android Auto: drop the Ecualizador browsable folder.

Owner decision after driving with it: a browsable six-preset list is
more interaction than a driver wants, and on/off from all three player
views (already fixed in 7054a4c to win the custom-action slot) is the
only equalizer control that belongs in the car. Preset selection stays on
the phone. This lands back on the redesign mockup's original rule ("sin
carpeta de ecualizador"), now for a road-tested reason. getChildren keeps
answering the folder's id transitionally, since a head unit can have the
old tree cached for a session or two.

The two "raiz always includes/ends with Ecualizador" tests are replaced,
not regressed -- same move the codebase already made once in the other
direction for the same folder.

Tests: 1127 -> 1132.
This commit is contained in:
2026-08-05 23:07:10 +02:00
parent 2bafc7e5ac
commit 80538900db
6 changed files with 326 additions and 59 deletions
+60 -6
View File
@@ -341,14 +341,41 @@ class EstadoAlarmas extends ChangeNotifier {
/// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten
/// lines below with the identical hazard and no guard, and it stayed that
/// way until a user lost a whole week of alarms. Do not re-inline it.
DateTime _ocurrenciaSonando(AlarmaMusical? alarma) {
DateTime _ocurrenciaSonando(AlarmaMusical? alarma) =>
_ocurrenciaValida(alarma);
/// How far ahead the PRE-NOTICE notification's occurrence may legitimately
/// sit: it is armed exactly this far before the alarm, so between the
/// reminder appearing and the user tapping it, the occurrence has not
/// happened yet and rejecting it would be wrong.
///
/// Mirrors `AlarmScheduler.PRE_NOTICE_MILLIS` (30 min). Both sides must
/// agree or one of them starts discarding perfectly good anchors.
static const ventanaPreaviso = Duration(minutes: 30);
/// [_ocurrenciaSonando] generalized with a forward allowance, and with an
/// externally-supplied [propuesta] taking priority when it survives the
/// same check.
///
/// [propuesta] is what the NATIVE side reported as the occurrence its
/// notification was about. It is trusted first — it is better evidence than
/// anything reconstructed here — but only after being validated, because it
/// can arrive as a fallback the caller invented (`app.dart` substitutes
/// `alarma.proximaEjecucion` when the native event carries no occurrence,
/// and that field may already point at tomorrow).
DateTime _ocurrenciaValida(
AlarmaMusical? alarma, {
DateTime? propuesta,
Duration margen = Duration.zero,
}) {
final ahora = servicio.ahora();
final limite = ahora.add(
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente,
);
DateTime? sonando(DateTime? candidata) =>
candidata != null && !candidata.isAfter(limite) ? candidata : null;
return sonando(alarma?.snoozeOrigen) ??
return sonando(propuesta) ??
sonando(alarma?.snoozeOrigen) ??
sonando(alarma?.proximaEjecucion) ??
sonando(alarma?.ultimaEjecucionGestionada) ??
ahora;
@@ -382,6 +409,24 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
/// "Posponer" on the PRE-NOTICE notification.
///
/// Reported on-device: this left the alarm snoozed for 1400+ minutes — a
/// whole day — instead of the configured few. The native lane got its guard
/// in 7054a4c, but Dart runs AFTERWARDS on this path (the receiver's
/// `postponeNext` fires, then `startActivity`, then this) and persists +
/// reschedules, so whatever it computes is the value that survives. It was
/// the last snooze path in the codebase with NO occurrence guard at all:
/// it took [ejecucion] on faith and turned it straight into the next alarm.
///
/// And [ejecucion] is not trustworthy: `app.dart` falls back to
/// `alarma.proximaEjecucion` whenever the native event carries no
/// occurrence, and that field can already point at tomorrow.
///
/// Validated through [_ocurrenciaValida] with a [ventanaPreaviso]
/// allowance — unlike the ringing-screen paths this occurrence legitimately
/// has NOT arrived yet, which is exactly why `_ocurrenciaSonando` could not
/// simply be reused here.
Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma,
int minutos,
@@ -389,14 +434,23 @@ class EstadoAlarmas extends ChangeNotifier {
) async {
_error = null;
final seguros = _snoozeSeguro(minutos);
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
final ocurrencia = _ocurrenciaValida(
alarma,
propuesta: ejecucion,
margen: ventanaPreaviso,
);
final snoozeHasta = ocurrencia.add(Duration(minutes: seguros));
debugPrint(
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros propuesta=${ejecucion.toIso8601String()} ocurrencia=${ocurrencia.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucionHasta(
alarma.id,
ejecucion,
// The VALIDATED occurrence, not the raw parameter: this becomes both
// `snoozeOrigen` and `ultimaEjecucionGestionada`, so passing the
// unchecked value here would poison the very state a9da855/0430059
// exist to keep clean.
ocurrencia,
snoozeHasta,
);
_aplicar(config);