fix(auto): preserve phone-chosen station order in Android Auto folders

Android Auto's Favoritos/Todas/Mis emisoras folders always re-sorted by
a hardcoded quality criterion in ConstructorArbolAuto.hijos/hijosGrupo,
discarding whatever order the caller passed in. EstadoRadio now pushes
already-ordered snapshots (listaFavoritosManual for Favoritos, and the
ordenListas-sorted populares/emisorasCustom getters for Todas/Mis
emisoras, re-pushed immediately on cambiarOrdenListas), and hijos/
hijosGrupo stop re-sorting so that order survives into the car.
This commit is contained in:
2026-08-01 11:26:24 +02:00
parent d945e1a313
commit cfd8bc9e6a
4 changed files with 201 additions and 29 deletions
+34 -6
View File
@@ -394,7 +394,14 @@ class EstadoRadio extends ChangeNotifier {
_cargandoPopulares = false; _cargandoPopulares = false;
// Design "live snapshot the source prefers": Android Auto's `Todas` // Design "live snapshot the source prefers": Android Auto's `Todas`
// folder mirrors the same populares list the phone just loaded. // folder mirrors the same populares list the phone just loaded.
_fuenteAuto?.actualizarSnapshot(todas: _populares); //
// Fix `android-auto-orden`: pushes the SORTED [populares] getter, not
// the raw [_populares] field — the same [_ordenListas] setting the
// phone's own discovery lists (e.g. Buscar's `tendencias`) already
// sort by must also govern this folder's order, not the API's raw
// arrival order. `navegacion_auto.dart`'s `hijos()` no longer
// re-sorts, so whatever order arrives here IS what the driver sees.
_fuenteAuto?.actualizarSnapshot(todas: populares);
notifyListeners(); notifyListeners();
} }
} }
@@ -402,7 +409,13 @@ class EstadoRadio extends ChangeNotifier {
Future<void> cargarFavoritos() async { Future<void> cargarFavoritos() async {
_listaFavoritos = await favoritos.obtenerTodos(); _listaFavoritos = await favoritos.obtenerTodos();
await _normalizarEmisoraPreferida(); await _normalizarEmisoraPreferida();
_fuenteAuto?.actualizarSnapshot(favoritos: _listaFavoritos); // Fix `android-auto-orden`: pushes the documented manual-order accessor
// explicitly. [listaFavoritosManual] is backed by the same list as
// [_listaFavoritos] today (obtenerTodos() already returns the persisted
// manual order), but naming the intent here — "the exact order the
// Favoritos screen shows and reorders" — keeps this call from silently
// drifting onto a re-sorted list in a future refactor.
_fuenteAuto?.actualizarSnapshot(favoritos: listaFavoritosManual);
notifyListeners(); notifyListeners();
} }
@@ -520,6 +533,16 @@ class EstadoRadio extends ChangeNotifier {
await prefs.setString(_keyOrdenListas, orden.name); await prefs.setString(_keyOrdenListas, orden.name);
// Search owns its own listeners (S4-R3) but sorts with this preference. // Search owns its own listeners (S4-R3) but sorts with this preference.
busqueda.notificarCambioOrden(); busqueda.notificarCambioOrden();
// Fix `android-auto-orden`: Todas/Mis emisoras' Android Auto order is
// derived from this same setting (see cargarPopulares/
// _cargarEmisorasCustom above) — without an immediate re-push, a live
// car session would keep showing the OLD order until the next full
// reload instead of updating right away, same as the phone does via
// this method's own memoized getters.
_fuenteAuto?.actualizarSnapshot(
todas: populares,
misEmisoras: emisorasCustom,
);
notifyListeners(); notifyListeners();
} }
@@ -659,7 +682,9 @@ class EstadoRadio extends ChangeNotifier {
detalle: 'resolucion de ruta', detalle: 'resolucion de ruta',
razon: e.toString(), razon: e.toString(),
); );
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom); // Fix `android-auto-orden`: pushes the SORTED [emisorasCustom] getter
// (see the doc on this method's other 3 identical call sites below).
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners(); notifyListeners();
return; return;
} }
@@ -685,7 +710,8 @@ class EstadoRadio extends ChangeNotifier {
razon: e.toString(), razon: e.toString(),
); );
} }
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom); // Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners(); notifyListeners();
} }
@@ -701,7 +727,8 @@ class EstadoRadio extends ChangeNotifier {
if (!await archivo.exists()) { if (!await archivo.exists()) {
_emisorasCustom = []; _emisorasCustom = [];
_customDegradado = false; _customDegradado = false;
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom); // Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners(); notifyListeners();
return null; return null;
} }
@@ -714,7 +741,8 @@ class EstadoRadio extends ChangeNotifier {
detalle: archivo.path, detalle: archivo.path,
razon: e.toString(), razon: e.toString(),
); );
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom); // Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners(); notifyListeners();
return null; return null;
} }
+21 -13
View File
@@ -6,7 +6,6 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart'; import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart'; import '../modelos/pista_local.dart';
@@ -350,15 +349,23 @@ class ConstructorArbolAuto {
extras: _contentStyleLista, extras: _contentStyleLista,
); );
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at /// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" — /// capped at [_maxItemsPorCarpeta] (Design "which stations surface &
/// avoids driver distraction and Auto list limits). Unknown [parentId] /// ordering" — avoids driver distraction and Auto list limits).
/// (or an empty [emisoras]) returns an empty list instead of throwing. ///
/// Fix `android-auto-orden`: this used to force
/// `ordenarEmisoras(emisoras, OrdenEmisoras.calidad)` unconditionally,
/// silently discarding whatever order the caller actually wanted —
/// Favoritos' manual drag-reorder order, or the global `ordenListas`
/// setting for Todas/Mis emisoras. Every caller (`EstadoRadio.
/// cargarFavoritos`/`cargarPopulares`/`_cargarEmisorasCustom`/
/// `cambiarOrdenListas`) now pushes an already-ordered snapshot, so this
/// only slices and maps — it must never re-sort. Unknown [parentId] (or
/// an empty [emisoras]) returns an empty list instead of throwing.
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) { List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
if (!_idsCarpetas.contains(parentId)) return const []; if (!_idsCarpetas.contains(parentId)) return const [];
if (emisoras.isEmpty) return const []; if (emisoras.isEmpty) return const [];
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad); return emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
} }
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>` /// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
@@ -824,10 +831,12 @@ class ConstructorArbolAuto {
} }
/// Members of the favorite group identified by [grupoMediaId] (a /// Members of the favorite group identified by [grupoMediaId] (a
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car /// `grupo:<id>` id), PRESERVING the incoming [favoritos] order (Favoritos'
/// requests a group folder's stations"). An unknown/stale/malformed id /// manual order — see [hijos]' doc, fix `android-auto-orden`) and capped
/// returns an empty list instead of throwing (Spec "Car requests an /// like every other folder (Spec "Car requests a group folder's
/// unknown or stale group id"). /// stations"). An unknown/stale/malformed id returns an empty list
/// instead of throwing (Spec "Car requests an unknown or stale group
/// id").
List<MediaItem> hijosGrupo( List<MediaItem> hijosGrupo(
String grupoMediaId, { String grupoMediaId, {
required List<Emisora> favoritos, required List<Emisora> favoritos,
@@ -837,8 +846,7 @@ class ConstructorArbolAuto {
if (id.isEmpty) return const []; if (id.isEmpty) return const [];
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList(); final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
if (miembros.isEmpty) return const []; if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad); return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
} }
/// Equalizer preset-selection media-id prefix (decision /// Equalizer preset-selection media-id prefix (decision
+130
View File
@@ -607,6 +607,136 @@ void main() {
); );
}); });
test(
'fix android-auto-orden: el snapshot de favoritos es EXACTAMENTE '
'listaFavoritosManual (el orden manual que la pantalla Favoritos '
'muestra y reordena), nunca la lista cruda ni un getter reordenado',
() async {
final favoritosServicio = FakeServicioFavoritos();
// Agregados en orden Z, A: si el push usara un getter reordenado
// (p. ej. alfabético), 'alfa' iría primero. El orden manual conserva
// el orden de inserción/persistencia: Z, A.
await favoritosServicio.agregar(
emisoraDemo(uuid: 'zulu', nombre: 'Zulu Fav'),
);
await favoritosServicio.agregar(
emisoraDemo(uuid: 'alfa', nombre: 'Alfa Fav'),
);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritosServicio,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoFavoritos?.map((e) => e.uuid).toList(), [
'zulu',
'alfa',
]);
expect(fuenteAuto.ultimoFavoritos, equals(estado.listaFavoritosManual));
},
);
test('fix android-auto-orden: el snapshot de "Todas" honra el orden '
'global (ordenListas) igual que "Tendencias" en el teléfono, no el '
'orden crudo de llegada de la API', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
});
test('fix android-auto-orden: el snapshot de "Mis emisoras" honra el '
'orden global (ordenListas), no el orden crudo del archivo', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('fix android-auto-orden: cambiarOrdenListas re-empuja de inmediato '
'los snapshots de "Todas" y "Mis emisoras" con el nuevo orden, sin '
'esperar a la próxima recarga completa', () async {
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.cambiarOrdenListas(OrdenEmisoras.nombre);
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('reconcilia _emisoraSeleccionada cuando la selección viene desde ' test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())', () async { 'el auto (no via reproducir())', () async {
final audio = _AudioControlado(); final audio = _AudioControlado();
+16 -10
View File
@@ -2741,7 +2741,15 @@ void main() {
}); });
group('ConstructorArbolAuto.hijos', () { group('ConstructorArbolAuto.hijos', () {
test('limita a 50 items y respeta el orden de ordenarEmisoras', () { test('limita a 50 items y PRESERVA el orden recibido — item 2 '
'(android-auto-orden fix): ya no reordena por calidad. Cada llamador '
'(Favoritos/Todas/Mis emisoras) empuja el snapshot ya en el orden que '
'el teléfono usa (orden manual o el ajuste global ordenListas), así '
'que reordenar aquí volvería a descartarlo', () {
// bitrate ascendente a propósito: si hijos() todavía reordenara por
// calidad (bitrate descendente), el primero sería uuid-59, no
// uuid-0 — esta es la diferencia que distingue "preserva el orden"
// de "vuelve a ordenar".
final emisoras = List.generate( final emisoras = List.generate(
60, 60,
(i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i), (i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i),
@@ -2753,11 +2761,8 @@ void main() {
); );
expect(hijos, hasLength(50)); expect(hijos, hasLength(50));
// ordenarEmisoras por calidad ordena por bitrate descendente: el expect(hijos.first.id, 'emisora:uuid-0');
// primer item debe ser el de mayor bitrate (59) y el ultimo (el #50) expect(hijos.last.id, 'emisora:uuid-49');
// el de bitrate 10 (59..10 son 50 valores).
expect(hijos.first.id, 'emisora:uuid-59');
expect(hijos.last.id, 'emisora:uuid-10');
}); });
test('lista vacía cuando no hay emisoras, sin lanzar', () { test('lista vacía cuando no hay emisoras, sin lanzar', () {
@@ -2935,8 +2940,9 @@ void main() {
}); });
group('ConstructorArbolAuto.hijosGrupo', () { group('ConstructorArbolAuto.hijosGrupo', () {
test('filtra por grupoFavoritosId, ordena y capea a 50; id desconocido ' test('filtra por grupoFavoritosId, PRESERVA el orden recibido (ya no '
'devuelve lista vacía', () { 'reordena por calidad — item 2 android-auto-orden fix) y capea a 50; '
'id desconocido devuelve lista vacía', () {
final favoritos = List.generate( final favoritos = List.generate(
60, 60,
(i) => _emisora( (i) => _emisora(
@@ -2953,8 +2959,8 @@ void main() {
); );
expect(hijos, hasLength(50)); expect(hijos, hasLength(50));
expect(hijos.first.id, 'emisora:uuid-59'); expect(hijos.first.id, 'emisora:uuid-0');
expect(hijos.last.id, 'emisora:uuid-10'); expect(hijos.last.id, 'emisora:uuid-49');
final vacio = ConstructorArbolAuto().hijosGrupo( final vacio = ConstructorArbolAuto().hijosGrupo(
'grupo:g-inexistente', 'grupo:g-inexistente',