merge: dart-side snooze anchor guard, Auto progress bar, drop EQ folder

This commit is contained in:
2026-08-05 23:07:19 +02:00
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);
+13 -15
View File
@@ -315,20 +315,19 @@ class ConstructorArbolAuto {
/// optionally Música Local, Ecualizador), all non-playable.
///
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// folder" rule that used to live in this doc comment (commit `2403da3`,
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
/// line 40). That rule was sound when written, but predated on-device
/// feedback showing that Android Auto custom actions don't surface
/// enough state for choosing among six presets: a monochrome icon cannot
/// legibly encode "which preset", and many head units render a custom
/// action icon-first, hiding its label. `Ecualizador` is a real
/// browsable folder again: "Desactivar" first, then the six factory
/// presets, the active one marked (children built by
/// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays
/// free of any `AppLocalizations` dependency, unlike that builder).
/// Always present, and LAST in the list (after Música Local, when
/// included) -- unlike [idMusicaLocal] it is never conditionally hidden.
/// Do not "restore" the no-folder rule without re-reading that decision.
/// There is NO `Ecualizador` folder. The car's only equalizer control is
/// the on/off custom action on the playback screen
/// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which
/// the driver reaches from all three player views without leaving them.
///
/// The folder existed briefly (`8423ccd`) because custom actions were
/// thought unable to convey enough state for a six-preset choice. Owner
/// decision after driving with it: a browsable preset list is more
/// interaction than a driver wants, and on/off is the only equalizer
/// control that belongs in a car. Preset selection stays on the phone.
/// This lands back on the redesign mockup's original rule ("sin carpeta de
/// ecualizador", turn t4 line 40), now for a road-tested reason rather than
/// an assumed one.
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
@@ -339,7 +338,6 @@ class ConstructorArbolAuto {
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
_carpeta(idEcualizador, 'Ecualizador'),
];
MediaItem _carpeta(String id, String titulo) => MediaItem(
+14
View File
@@ -564,8 +564,22 @@ class ServicioAlarmas {
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.
// Self-heal for a snooze target parked absurdly far out — the reported
// "posponer left it 1400+ minutes away". A legitimate snooze can never
// reach here: posponerEjecucion clamps to `minutos.clamp(1, 120)` and the
// anchor is now guarded on both the native and Dart sides, so anything
// past that ceiling is a leftover from a build that had neither guard.
// Without this, an alarm poisoned before the fix keeps showing tomorrow
// on every tick — the user reinstalls, sees no change, and reasonably
// concludes nothing was fixed. Generous margin over the 120-minute cap so
// a real long snooze is never mistaken for corruption.
const techoSnooze = Duration(hours: 3);
final snoozeCorrupto =
alarma.snoozeHasta != null &&
alarma.snoozeHasta!.isAfter(ahora.add(techoSnooze));
final snoozeActivo =
alarma.activa &&
!snoozeCorrupto &&
alarma.snoozeHasta != null &&
alarma.snoozeHasta!.isAfter(ahora);
// Self-heal for state poisoned before the Detener anchor fix: a stop
+41 -1
View File
@@ -578,6 +578,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
late AudioPlayer _player = _crearPlayer();
StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub;
StreamSubscription<Duration?>? _duracionSub;
StreamSubscription<PlaybackEvent>? _eventosSub;
StreamSubscription<int?>? _androidAudioSessionIdSub;
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
@@ -720,6 +721,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
cambiandoFuente: _cambiandoFuente,
),
playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
@@ -728,7 +739,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
});
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos));
playbackState.add(
playbackState.value.copyWith(
bufferedPosition: pos,
// Must ride along: `copyWith` stamps a fresh `updateTime` but keeps
// the old `updatePosition`, so a push without it actively tells the
// client the PREVIOUS position is current NOW — freezing the bar
// between player-state events. This stream ticks ~2/s, which is
// what keeps the car's bar smooth.
updatePosition: _player.position,
),
);
});
// Duration arrives asynchronously once the source is parsed, and Android
// Auto draws no progress bar for a MediaItem without one. Radio streams
// report null (correct: live audio has no length) and are left alone.
_duracionSub = _player.durationStream.listen((duracion) {
final actual = mediaItem.value;
if (duracion == null || actual == null) return;
if (actual.duration == duracion) return;
mediaItem.add(actual.copyWith(duration: duracion));
});
_eventosSub = _player.playbackEventStream.listen(
@@ -1180,6 +1211,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> _recrearPlayer() async {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _duracionSub?.cancel();
await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
@@ -1486,6 +1518,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await stop();
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _duracionSub?.cancel();
await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
await _player.dispose();
@@ -1535,6 +1568,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// external data source, unlike every branch below it -- checked
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
// local-music branch above is also resolved before that gate.
// The Ecualizador folder is no longer offered by `raiz()` (owner
// decision: the car keeps only the on/off toggle on the playback
// screen). This branch stays as a TRANSITIONAL courtesy: Android Auto
// caches browse trees on the head unit, so a stale "Ecualizador" entry
// can survive the update for a session or two. Answering it keeps that
// leftover working instead of opening an empty dead folder. Delete
// once no head unit can still be holding the old tree.
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return itemsEcualizadorAuto(
activo: _ecualizadorActivo,
@@ -0,0 +1,163 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// Reported twice on-device: "Posponer" on the pre-notice notification left
/// the alarm snoozed 1400+ minutes — a whole day — instead of the configured
/// few minutes.
///
/// The first fix (7054a4c) guarded the NATIVE anchor, and it was not enough,
/// because Dart runs AFTERWARDS on this path: the receiver's `postponeNext`
/// fires, then `startActivity`, then `app.dart` dispatches here, and this
/// method persists and reschedules. Whatever Dart computes is the value that
/// survives. It was the last snooze path with no occurrence guard at all.
///
/// It also cannot simply reuse `_ocurrenciaSonando`: the pre-notice's
/// occurrence legitimately has NOT arrived yet (the reminder is armed 30 min
/// ahead), so the ringing-screen guard would reject a perfectly good anchor.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
AlarmaMusical diaria(String id) => AlarmaMusical(
id: id,
nombre: 'Mañana',
hora: 16,
minuto: 20,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
snoozeMinutos: 5,
);
({EstadoAlarmas estado, FakePuertoAlarmasAndroid android}) montar(
DateTime Function() reloj,
) {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: reloj),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
return (estado: estado, android: android);
}
test('una ocurrencia del DÍA SIGUIENTE se rechaza: pospone minutos, '
'no 24 horas', () async {
// The exact reported shape. The pre-notice for today's 16:20 is on
// screen at 16:16; the anchor handed in points at TOMORROW (either the
// native spec was already advanced, or app.dart fell back to a
// proximaEjecucion that had moved on).
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p1'));
ahora = DateTime(2026, 8, 3, 16, 16);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2026, 8, 4, 16, 20), // <- tomorrow
);
final snooze = m.estado.alarmas.single.snoozeHasta!;
final minutos = snooze.difference(ahora).inMinutes;
expect(
minutos,
lessThan(60),
reason:
'la alarma quedó a $minutos min ($snooze). El reporte fue "más de '
'1400 minutos"; cualquier cosa por encima de una hora es el mismo bug',
);
expect(
m.estado.alarmas.single.snoozeOrigen,
isNot(DateTime(2026, 8, 4, 16, 20)),
reason:
'el ancla sin validar también se guarda como snoozeOrigen y como '
'ultimaEjecucionGestionada — envenenaría el estado que a9da855 y '
'0430059 existen para mantener limpio',
);
});
test('la ocurrencia REAL del preaviso se respeta aunque esté en el futuro: '
'ancla en la ocurrencia + N, no en ahora + N', () async {
// The whole reason this path needs its own guard instead of reusing
// _ocurrenciaSonando: 30 minutes ahead is legitimate here.
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p2'));
// Pre-notice fires at 15:50; the user taps at 15:52, 28 min before.
ahora = DateTime(2026, 8, 3, 15, 52);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2026, 8, 3, 16, 20),
);
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
expect(m.estado.alarmas.single.snoozeOrigen, DateTime(2026, 8, 3, 16, 20));
});
test('un snooze ya envenenado en disco se cura al recalcular', () async {
// Devices that ran the buggy build carry snoozeHasta = tomorrow in
// SharedPreferences. Without healing it, the alarm keeps reporting
// tomorrow on every tick and the user sees no change after updating.
final ahora = DateTime(2026, 8, 3, 16, 0);
final servicio = ServicioAlarmas(reloj: () => ahora);
await servicio.guardarAlarma(
diaria('p4').copyWith(
snoozeHasta: DateTime(2026, 8, 4, 16, 25),
snoozeOrigen: DateTime(2026, 8, 4, 16, 20),
),
);
final alarma = (await servicio.recalcularTodas()).alarmas.single;
expect(alarma.snoozeHasta, isNull);
expect(alarma.proximaProgramable, DateTime(2026, 8, 3, 16, 20));
});
test('un snooze legítimo de 2 horas NO se toca', () async {
// posponerEjecucion clamps to 120 minutes, so the ceiling has to sit
// above that or the heal would eat real snoozes.
final ahora = DateTime(2026, 8, 3, 16, 0);
final servicio = ServicioAlarmas(reloj: () => ahora);
final hasta = DateTime(2026, 8, 3, 18, 0);
await servicio.guardarAlarma(
diaria('p5').copyWith(snoozeHasta: hasta, snoozeOrigen: ahora),
);
expect(
(await servicio.recalcularTodas()).alarmas.single.snoozeHasta,
hasta,
);
});
test('un ancla absurdamente lejana cae a la ocurrencia propia de la alarma, '
'no a un valor inventado', () async {
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p3'));
ahora = DateTime(2026, 8, 3, 16, 10);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2027, 1, 1, 16, 20), // absurd
);
// proximaEjecucion (today 16:20) is inside the pre-notice window, so it
// is the right fallback and the snooze lands on 16:25.
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
});
}
+35 -37
View File
@@ -238,37 +238,10 @@ void main() {
});
group('ConstructorArbolAuto.raiz', () {
test('con incluirMusicaLocal: true devuelve exactamente 5 carpetas no '
'reproducibles con los ids esperados, terminando en Ecualizador', () {
test('con incluirMusicaLocal: true devuelve exactamente 4 carpetas no '
'reproducibles con los ids esperados', () {
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true);
expect(raiz, hasLength(5));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idEcualizador,
}),
);
for (final item in raiz) {
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
}
// Decision `auto/ecualizador-diseno`: Ecualizador is always LAST,
// after Música Local when it is present.
expect(raiz[raiz.length - 2].id, ConstructorArbolAuto.idMusicaLocal);
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
});
test('con incluirMusicaLocal: false devuelve exactamente 4 carpetas — '
'Música Local queda OCULTA, no vacía, y Ecualizador sigue presente y '
'al final', () {
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
expect(raiz, hasLength(4));
final ids = raiz.map((item) => item.id).toSet();
expect(
@@ -277,25 +250,50 @@ void main() {
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idEcualizador,
ConstructorArbolAuto.idMusicaLocal,
}),
);
for (final item in raiz) {
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
}
expect(raiz.last.id, ConstructorArbolAuto.idMusicaLocal);
});
test('con incluirMusicaLocal: false devuelve exactamente 3 carpetas — '
'Música Local queda OCULTA, no vacía', () {
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
expect(raiz, hasLength(3));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
}),
);
expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal)));
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
});
test('la raíz ofrece la carpeta de ecualizador -- decisión '
'`auto/ecualizador-diseno` SUPERSEDE la regla anterior de "sin '
'carpeta de ecualizador" (commit `2403da3`); este test REEMPLAZA '
'deliberadamente al test homónimo previo que afirmaba lo contrario, '
'no es una regresión', () {
test('la raíz NO ofrece la carpeta de ecualizador -- decisión owner '
'tras conducir con ella: on/off en pantalla es el único control de '
'ecualizador que pertenece en el coche, la selección de preset se '
'queda en el móvil. Esto REEMPLAZA deliberadamente al test homónimo '
'previo que afirmaba lo contrario (que a su vez había reemplazado la '
'ausencia original en `2403da3`) -- no es una regresión, es la '
'segunda vuelta de la misma decisión con evidencia real de uso', () {
final ids =
ConstructorArbolAuto()
.raiz(incluirMusicaLocal: true)
.map((item) => item.id)
.toList();
expect(ids, contains(ConstructorArbolAuto.idEcualizador));
expect(ids, isNot(contains(ConstructorArbolAuto.idEcualizador)));
// The id constant itself is NOT deleted: getChildren still answers it
// transitionally for a head unit with a stale cached browse tree. See
// servicio_audio.dart's getChildren for that courtesy branch.
expect(ConstructorArbolAuto.idEcualizador, 'ecualizador');
});
});