feat: restaurar los grupos de favoritos al importar y recordar la lista del coche

Dos fallos reportados desde el uso real en el coche.

Los grupos de favoritos no volvian al importar

La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la
asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo
estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar,
que fuerza "sin asignar" a proposito, porque una emisora recien marcada como
favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de
restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se
separa la ruta de restaurar, que respeta el grupo_id del backup y su orden.

Ningun test lo detectaba porque el doble de pruebas era infiel: el fake
conservaba el grupo que la implementacion real destruia, y no tenia
restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo
esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble.

No hace falta subir la version del formato: el envoltorio ya llevaba todo.

El coche perdia la lista al reconectar

El contexto de reproduccion vivia solo en memoria, y el motor que arranca
Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al
reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y
siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se
persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve
contra las listas vivas en cada salto: si el grupo cambia de contenido entre
sesiones, el coche ve lo actual y no una foto vieja.

Cadena de repliegue, decidida por el propietario:
  grupo vivo con la emisora dentro  -> se recorre el grupo
  grupo vivo sin la emisora         -> se permanece en el grupo, primera
  grupo vivo pero vacio             -> se ensancha a favoritos
  grupo borrado                     -> favoritos
  sin favoritos                     -> comportamiento actual

Honrar un grupo de una sola emisora exigia levantar dos barreras, no una:
el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con
menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a
ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora
se salio del grupo" se trata en el flujo del salto.

La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo
solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta
en vez de recorrerse.

Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
This commit is contained in:
2026-09-04 13:26:00 +02:00
parent 8e155cc0ac
commit 8a71bc237f
12 changed files with 1994 additions and 27 deletions
+52 -5
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'contexto_reproduccion.dart';
import 'emisoras_destacadas.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
@@ -1246,6 +1247,52 @@ List<Emisora> listaParaSaltoEmisora({
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
final contexto = contextoParaSaltoEmisora(
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
);
if (contexto == null) return const [];
switch (contexto.tipo) {
case TipoContextoSalto.grupoFavoritos:
return favoritos
.where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId)
.toList();
case TipoContextoSalto.favoritos:
return favoritos;
case TipoContextoSalto.misEmisoras:
return misEmisoras;
case TipoContextoSalto.todas:
return todas;
case TipoContextoSalto.destacadas:
// Never produced by [contextoParaSaltoEmisora] — the free set is
// resolved by the handler, which owns the entitlement read.
return const [];
}
}
/// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised
/// — so it can be remembered across a process restart.
///
/// The car kills and restarts the engine on every reconnect, and a list of
/// stations is not something that survives that: its members change while the
/// app is dead. The NAME of the list does survive, which is what
/// [ContextoSalto] persists and [resolverListaContexto] re-resolves against
/// whatever the lists hold next time.
///
/// [listaParaSaltoEmisora] is implemented on top of this so the walked list
/// and the remembered context can never disagree (pinned by a test that runs
/// both over the same scenarios).
///
/// Returns `null` when [actual] belongs to none of the three lists — the
/// caller then has no context to remember and leaves playback alone.
ContextoSalto? contextoParaSaltoEmisora({
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
Emisora? enLista(List<Emisora> lista) {
for (final e in lista) {
@@ -1263,13 +1310,13 @@ List<Emisora> listaParaSaltoEmisora({
if (grupo != GrupoFavoritos.sinAsignarId) {
final delGrupo =
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
if (delGrupo.length > 1) return delGrupo;
if (delGrupo.length > 1) return ContextoSalto.grupo(grupo);
}
return favoritos;
return const ContextoSalto.favoritos();
}
if (enLista(misEmisoras) != null) return misEmisoras;
if (enLista(todas) != null) return todas;
return const [];
if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras();
if (enLista(todas) != null) return const ContextoSalto.todas();
return null;
}
/// The station before or after [actual] in [lista], wrapping around at both