feat(auto): surface favorite groups as Android Auto sub-folders

Favoritos now renders non-empty custom groups as grupo:<id>
sub-folders (hidden when empty) with ungrouped stations left as
direct leaves, reusing the existing hijos() path so the zero-groups
case stays byte-identical to today's flat list.
This commit is contained in:
2026-07-19 13:42:25 +02:00
parent f9003436ea
commit f368bcc777
11 changed files with 812 additions and 1 deletions
+11
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
@@ -584,6 +585,10 @@ void main() {
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
contains('pop-auto-1'),
);
expect(
fuenteAuto.ultimoGrupos?.map((g) => g.id),
contains(GrupoFavoritos.sinAsignarId),
);
await estado.toggleFavorito(emisoraFav);
@@ -631,15 +636,18 @@ class _FuenteEmisorasAutoEspia implements FuenteEmisorasAuto {
List<Emisora>? ultimoFavoritos;
List<Emisora>? ultimoMisEmisoras;
List<Emisora>? ultimoTodas;
List<GrupoFavoritos>? ultimoGrupos;
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {
if (favoritos != null) ultimoFavoritos = favoritos;
if (misEmisoras != null) ultimoMisEmisoras = misEmisoras;
if (todas != null) ultimoTodas = todas;
if (grupos != null) ultimoGrupos = grupos;
}
@override
@@ -651,6 +659,9 @@ class _FuenteEmisorasAutoEspia implements FuenteEmisorasAuto {
@override
Future<List<Emisora>> todas() async => ultimoTodas ?? const [];
@override
Future<List<GrupoFavoritos>> grupos() async => ultimoGrupos ?? const [];
@override
Future<Emisora?> porUuid(String uuid) async => null;
}
+177
View File
@@ -1,6 +1,7 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
void main() {
@@ -401,6 +402,168 @@ void main() {
);
});
group('ConstructorArbolAuto.esCarpetaGrupo', () {
test('reconoce ids con el prefijo grupo:, rechaza el resto', () {
final builder = ConstructorArbolAuto();
expect(builder.esCarpetaGrupo('grupo:g1'), isTrue);
expect(builder.esCarpetaGrupo('favoritos'), isFalse);
expect(builder.esCarpetaGrupo('emisora:x'), isFalse);
expect(builder.esCarpetaGrupo(''), isFalse);
});
});
group('ConstructorArbolAuto.itemGrupo', () {
test('mapea un GrupoFavoritos a una carpeta no reproducible con id '
'grupo:<id>', () {
final grupo = _grupo(id: 'g1', nombre: 'Rock');
final item = ConstructorArbolAuto().itemGrupo(grupo);
expect(item.id, 'grupo:g1');
expect(item.playable, isFalse);
expect(item.title, grupo.nombre);
});
});
group('ConstructorArbolAuto.carpetasFavoritos', () {
test('sin grupos personalizados, el resultado es idéntico a hijos '
'(idFavoritos, emisoras: favoritos) — regresión más importante de '
'este cambio', () {
final favoritos = [
_emisora(uuid: 'uuid-1', nombre: 'Radio 1', bitrate: 128),
_emisora(uuid: 'uuid-2', nombre: 'Radio 2', bitrate: 64),
];
final builder = ConstructorArbolAuto();
final resultado = builder.carpetasFavoritos(
grupos: const [
GrupoFavoritos(id: GrupoFavoritos.sinAsignarId, nombre: 'Sin asignar', orden: 0),
],
favoritos: favoritos,
);
final esperado = builder.hijos(
ConstructorArbolAuto.idFavoritos,
emisoras: favoritos,
);
expect(resultado.map((i) => i.id).toList(), esperado.map((i) => i.id).toList());
expect(resultado.length, esperado.length);
for (var i = 0; i < resultado.length; i++) {
expect(resultado[i].id, esperado[i].id);
expect(resultado[i].playable, esperado[i].playable);
expect(resultado[i].title, esperado[i].title);
}
});
test('con un grupo personalizado no vacío, devuelve la carpeta grupo:<id> '
'seguida de las emisoras sin asignar como hojas', () {
final rock = _grupo(id: 'g-rock', nombre: 'Rock', orden: 1);
final sinAsignar = const GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
);
final favoritos = [
_emisora(uuid: 'uuid-rock', nombre: 'Radio Rock', grupoId: 'g-rock'),
_emisora(uuid: 'uuid-suelta', nombre: 'Radio Suelta'),
];
final resultado = ConstructorArbolAuto().carpetasFavoritos(
grupos: [sinAsignar, rock],
favoritos: favoritos,
);
expect(resultado, hasLength(2));
expect(resultado[0].id, 'grupo:g-rock');
expect(resultado[0].playable, isFalse);
expect(resultado[1].id, 'emisora:uuid-suelta');
expect(resultado[1].playable, isTrue);
});
test('un grupo personalizado vacío se omite; sin_asignar nunca aparece '
'como carpeta propia aunque esté en la lista de grupos', () {
final vacio = _grupo(id: 'g-vacio', nombre: 'Vacío', orden: 1);
final sinAsignar = const GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
);
final favoritos = [_emisora(uuid: 'uuid-1', nombre: 'Radio 1')];
final resultado = ConstructorArbolAuto().carpetasFavoritos(
grupos: [sinAsignar, vacio],
favoritos: favoritos,
);
expect(resultado.map((i) => i.id), isNot(contains('grupo:g-vacio')));
expect(
resultado.map((i) => i.id),
isNot(contains('grupo:${GrupoFavoritos.sinAsignarId}')),
);
expect(resultado.map((i) => i.id), contains('emisora:uuid-1'));
});
test('más de 50 grupos elegibles no vacíos se truncan a 50', () {
final favoritos = List.generate(
60,
(i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', grupoId: 'g-$i'),
);
final grupos = List.generate(
60,
(i) => _grupo(id: 'g-$i', nombre: 'Grupo $i', orden: i),
);
final resultado = ConstructorArbolAuto().carpetasFavoritos(
grupos: grupos,
favoritos: favoritos,
);
final carpetas = resultado.where((i) => i.playable != true).toList();
expect(carpetas, hasLength(50));
});
});
group('ConstructorArbolAuto.hijosGrupo', () {
test('filtra por grupoFavoritosId, ordena y capea a 50; id desconocido '
'devuelve lista vacía', () {
final favoritos = List.generate(
60,
(i) => _emisora(
uuid: 'uuid-$i',
nombre: 'Radio $i',
bitrate: i,
grupoId: 'g-rock',
),
);
final hijos = ConstructorArbolAuto().hijosGrupo(
'grupo:g-rock',
favoritos: favoritos,
);
expect(hijos, hasLength(50));
expect(hijos.first.id, 'emisora:uuid-59');
expect(hijos.last.id, 'emisora:uuid-10');
final vacio = ConstructorArbolAuto().hijosGrupo(
'grupo:g-inexistente',
favoritos: favoritos,
);
expect(vacio, isEmpty);
});
});
group('ConstructorArbolAuto.resolver: colisión con esquema grupo:', () {
test('un id grupo:<id> nunca resuelve a una Emisora', () {
final universo = [_emisora(uuid: 'g1', nombre: 'No debería matchear')];
final resultado = ConstructorArbolAuto().resolver('grupo:g1', universo);
expect(resultado, isNull);
});
});
group('reproducirPorMediaId', () {
test(
'resuelve el id y delega a reproducir con un MediaItem con forma de '
@@ -457,6 +620,7 @@ Emisora _emisora({
String? favicon,
String? codec,
int? bitrate,
String grupoId = GrupoFavoritos.sinAsignarId,
}) {
return Emisora(
uuid: uuid,
@@ -465,9 +629,18 @@ Emisora _emisora({
favicon: favicon,
codec: codec,
bitrate: bitrate,
grupoFavoritosId: grupoId,
);
}
GrupoFavoritos _grupo({
required String id,
required String nombre,
int orden = 0,
}) {
return GrupoFavoritos(id: id, nombre: nombre, orden: orden);
}
class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
_FakeFuenteEmisorasAuto({required Map<String, Emisora> porUuidResultado})
: _porUuidResultado = porUuidResultado;
@@ -486,10 +659,14 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
@override
Future<Emisora?> porUuid(String uuid) async => _porUuidResultado[uuid];
@override
Future<List<GrupoFavoritos>> grupos() async => const [];
@override
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {}
}