diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 5bf7e08..4236b50 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -394,7 +394,14 @@ class EstadoRadio extends ChangeNotifier { _cargandoPopulares = false; // Design "live snapshot the source prefers": Android Auto's `Todas` // 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(); } } @@ -402,7 +409,13 @@ class EstadoRadio extends ChangeNotifier { Future cargarFavoritos() async { _listaFavoritos = await favoritos.obtenerTodos(); 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(); } @@ -520,6 +533,16 @@ class EstadoRadio extends ChangeNotifier { await prefs.setString(_keyOrdenListas, orden.name); // Search owns its own listeners (S4-R3) but sorts with this preference. 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(); } @@ -659,7 +682,9 @@ class EstadoRadio extends ChangeNotifier { detalle: 'resolucion de ruta', 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(); return; } @@ -685,7 +710,8 @@ class EstadoRadio extends ChangeNotifier { razon: e.toString(), ); } - _fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom); + // Fix `android-auto-orden`: sorted getter, not the raw field. + _fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom); notifyListeners(); } @@ -701,7 +727,8 @@ class EstadoRadio extends ChangeNotifier { if (!await archivo.exists()) { _emisorasCustom = []; _customDegradado = false; - _fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom); + // Fix `android-auto-orden`: sorted getter, not the raw field. + _fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom); notifyListeners(); return null; } @@ -714,7 +741,8 @@ class EstadoRadio extends ChangeNotifier { detalle: archivo.path, razon: e.toString(), ); - _fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom); + // Fix `android-auto-orden`: sorted getter, not the raw field. + _fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom); notifyListeners(); return null; } diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index b2ad3d8..1965e93 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -6,7 +6,6 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path_provider/path_provider.dart'; -import '../estado/orden_emisoras.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; @@ -350,15 +349,23 @@ class ConstructorArbolAuto { extras: _contentStyleLista, ); - /// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at - /// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" — - /// avoids driver distraction and Auto list limits). Unknown [parentId] - /// (or an empty [emisoras]) returns an empty list instead of throwing. + /// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and + /// capped at [_maxItemsPorCarpeta] (Design "which stations surface & + /// ordering" — avoids driver distraction and Auto list limits). + /// + /// 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 hijos(String parentId, {required List emisoras}) { if (!_idsCarpetas.contains(parentId)) return const []; if (emisoras.isEmpty) return const []; - final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad); - return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); + return emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); } /// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:` @@ -824,10 +831,12 @@ class ConstructorArbolAuto { } /// Members of the favorite group identified by [grupoMediaId] (a - /// `grupo:` id), sorted and capped like every other folder (Spec "Car - /// requests a group folder's stations"). An unknown/stale/malformed id - /// returns an empty list instead of throwing (Spec "Car requests an - /// unknown or stale group id"). + /// `grupo:` id), PRESERVING the incoming [favoritos] order (Favoritos' + /// manual order — see [hijos]' doc, fix `android-auto-orden`) and capped + /// like every other folder (Spec "Car requests a group folder's + /// stations"). An unknown/stale/malformed id returns an empty list + /// instead of throwing (Spec "Car requests an unknown or stale group + /// id"). List hijosGrupo( String grupoMediaId, { required List favoritos, @@ -837,8 +846,7 @@ class ConstructorArbolAuto { if (id.isEmpty) return const []; final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList(); if (miembros.isEmpty) return const []; - final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad); - return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); + return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); } /// Equalizer preset-selection media-id prefix (decision diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index f8d2f76..b1fd6c5 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -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 ' 'el auto (no via reproducir())', () async { final audio = _AudioControlado(); diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 674452a..5328d9f 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2741,7 +2741,15 @@ void main() { }); 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( 60, (i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i), @@ -2753,11 +2761,8 @@ void main() { ); expect(hijos, hasLength(50)); - // ordenarEmisoras por calidad ordena por bitrate descendente: el - // primer item debe ser el de mayor bitrate (59) y el ultimo (el #50) - // el de bitrate 10 (59..10 son 50 valores). - expect(hijos.first.id, 'emisora:uuid-59'); - expect(hijos.last.id, 'emisora:uuid-10'); + expect(hijos.first.id, 'emisora:uuid-0'); + expect(hijos.last.id, 'emisora:uuid-49'); }); test('lista vacía cuando no hay emisoras, sin lanzar', () { @@ -2935,8 +2940,9 @@ void main() { }); group('ConstructorArbolAuto.hijosGrupo', () { - test('filtra por grupoFavoritosId, ordena y capea a 50; id desconocido ' - 'devuelve lista vacía', () { + test('filtra por grupoFavoritosId, PRESERVA el orden recibido (ya no ' + 'reordena por calidad — item 2 android-auto-orden fix) y capea a 50; ' + 'id desconocido devuelve lista vacía', () { final favoritos = List.generate( 60, (i) => _emisora( @@ -2953,8 +2959,8 @@ void main() { ); expect(hijos, hasLength(50)); - expect(hijos.first.id, 'emisora:uuid-59'); - expect(hijos.last.id, 'emisora:uuid-10'); + expect(hijos.first.id, 'emisora:uuid-0'); + expect(hijos.last.id, 'emisora:uuid-49'); final vacio = ConstructorArbolAuto().hijosGrupo( 'grupo:g-inexistente',