Files
pluriwave/lib/servicios/contexto_reproduccion.dart
FreeTLab 5f35ab7d6a
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m27s
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.
2026-09-02 22:56:10 +02:00

279 lines
11 KiB
Dart

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
/// The skip context's persistence key.
///
/// Same headless-safe shape as `emisoras_destacadas.dart`: this file imports
/// nothing but `shared_preferences` and the models, never `EstadoRadio` nor
/// anything that drags a `ChangeNotifier` graph in. Android Auto starts the
/// engine WITHOUT an Activity, so there is no widget tree and `EstadoRadio` is
/// never constructed there — a context only that class could write would be a
/// context the car can never have.
///
/// `contexto_reproduccion_test.dart` pins the literal so a rename fails loudly
/// instead of silently leaving every driver context-less after an update.
const claveContextoSalto = 'contexto_salto_v1';
/// Which LIST the driver is walking with the car's previous/next buttons.
///
/// The type is the durable part; the members are not. A group's contents
/// change between sessions (the phone renames it, empties it, deletes it), so
/// remembering the members would be remembering something that expires —
/// [resolverListaContexto] re-resolves against the LIVE lists every time.
enum TipoContextoSalto {
/// One favourites group. The only type that carries [ContextoSalto.grupoFavoritosId].
grupoFavoritos,
favoritos,
misEmisoras,
/// The `populares` catalogue snapshot.
todas,
/// The free tier's curated set (`emisorasDestacadas`). The ONLY type that
/// carries [ContextoSalto.uuidsOrdenados] — see that field.
destacadas,
}
/// The remembered playback context: the smallest thing that still identifies
/// the list on the other side of a process restart.
class ContextoSalto {
/// One favourites group, named by its stable id.
const ContextoSalto.grupo(String grupoId)
: tipo = TipoContextoSalto.grupoFavoritos,
grupoFavoritosId = grupoId,
uuidsOrdenados = const [];
const ContextoSalto.favoritos()
: tipo = TipoContextoSalto.favoritos,
grupoFavoritosId = null,
uuidsOrdenados = const [];
const ContextoSalto.misEmisoras()
: tipo = TipoContextoSalto.misEmisoras,
grupoFavoritosId = null,
uuidsOrdenados = const [];
const ContextoSalto.todas()
: tipo = TipoContextoSalto.todas,
grupoFavoritosId = null,
uuidsOrdenados = const [];
/// The free tier's set, FROZEN in [uuids] order.
const ContextoSalto.destacadas(List<String> uuids)
: tipo = TipoContextoSalto.destacadas,
grupoFavoritosId = null,
uuidsOrdenados = uuids;
final TipoContextoSalto tipo;
/// Set only for [TipoContextoSalto.grupoFavoritos].
final String? grupoFavoritosId;
/// The frozen order, set only for [TipoContextoSalto.destacadas].
///
/// Every other type resolves against a list that HAS a stable, user-owned
/// order (the favourites' `orden` column, the custom-stations file, the
/// catalogue snapshot), so freezing it would only mean ignoring a reorder
/// the user just made on the phone. The free set is the exception:
/// `resolverEmisorasDestacadas` rebuilds it as `[última reproducida,
/// ...curadas]`, so it REORDERS ITSELF as the driver skips, and `previous`
/// stops being the inverse of `next`. Freezing that order is the fix.
final List<String> uuidsOrdenados;
Map<String, dynamic> aMapa() => {
'tipo': tipo.name,
if (grupoFavoritosId != null) 'grupoId': grupoFavoritosId,
if (uuidsOrdenados.isNotEmpty) 'uuids': uuidsOrdenados,
};
/// Parses a persisted map, or `null` when it is unusable.
///
/// Tolerant on purpose: this payload survives app updates, backups and
/// hand-edited preference files, and it is read from a steering-wheel
/// button. An unreadable context must mean "derive it again", never a
/// crash.
static ContextoSalto? desdeMapa(Map<String, dynamic> mapa) {
final tipoRaw = mapa['tipo'];
if (tipoRaw is! String) return null;
final tipo = TipoContextoSalto.values
.where((t) => t.name == tipoRaw)
.firstOrNull;
if (tipo == null) return null;
switch (tipo) {
case TipoContextoSalto.grupoFavoritos:
final grupoId = mapa['grupoId'];
// A group context with no group is not a context.
if (grupoId is! String || grupoId.isEmpty) return null;
return ContextoSalto.grupo(grupoId);
case TipoContextoSalto.favoritos:
return const ContextoSalto.favoritos();
case TipoContextoSalto.misEmisoras:
return const ContextoSalto.misEmisoras();
case TipoContextoSalto.todas:
return const ContextoSalto.todas();
case TipoContextoSalto.destacadas:
final uuids = mapa['uuids'];
if (uuids is! List) return null;
return ContextoSalto.destacadas(uuids.whereType<String>().toList());
}
}
@override
bool operator ==(Object other) =>
other is ContextoSalto &&
other.tipo == tipo &&
other.grupoFavoritosId == grupoFavoritosId &&
_mismosUuids(other.uuidsOrdenados, uuidsOrdenados);
@override
int get hashCode => Object.hash(
tipo,
grupoFavoritosId,
Object.hashAll(uuidsOrdenados),
);
@override
String toString() =>
'ContextoSalto(${tipo.name}, grupo=$grupoFavoritosId, '
'uuids=${uuidsOrdenados.length})';
static bool _mismosUuids(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
/// Persists [contexto]. Never throws — a failed write costs the driver a
/// re-derivation, an exception would cost them the station change.
Future<void> guardarContextoSalto(
ContextoSalto contexto, {
SharedPreferences? prefs,
}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
await resueltas.setString(claveContextoSalto, jsonEncode(contexto.aMapa()));
} catch (_) {
// Deliberately swallowed — see the doc above.
}
}
/// Reads the persisted context, or `null` when there is none, the payload is
/// unreadable, or prefs themselves fail.
///
/// Follows the same inject-or-`getInstance()` convention as
/// `esPremiumPersistido` and `resolverEmisorasDestacadas`, so a test pins
/// prefs without a platform channel.
Future<ContextoSalto?> contextoSaltoPersistido({
SharedPreferences? prefs,
}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
final raw = resueltas.getString(claveContextoSalto);
if (raw == null) return null;
final decodificado = jsonDecode(raw);
if (decodificado is! Map) return null;
return ContextoSalto.desdeMapa(Map<String, dynamic>.from(decodificado));
} catch (_) {
return null;
}
}
/// The LIVE, ordered list a remembered [contexto] resolves to right now, or an
/// empty list when it no longer resolves at all.
///
/// Pure — no handler, no prefs — so every degradation rule below is testable
/// on its own. An empty result means "this memory has expired": the caller
/// derives a fresh context instead (and, failing that, leaves playback alone —
/// never jumps somewhere arbitrary mid-drive).
///
/// Degradation rules, all of them deliberate. The group chain is the owner's,
/// decided from real use in the car:
/// * remembered group ALIVE -> it is walked, even when the playing station
/// has LEFT it (the caller then takes the group's first station) and even
/// when it is down to a single member (skipping there simply leaves the
/// driver where they are — a one-station group is still a group).
/// * remembered group DELETED, or alive but EMPTY -> widen to all
/// favourites, whether or not the playing station is still one of them:
/// "if the whole group is gone, pick a station from the favourites".
/// * no favourites left -> empty, i.e. the no-stations behaviour.
/// * every OTHER context type still expires when the playing station left
/// its list (unfavourited, removed from the catalogue snapshot) — the
/// owner's decision was about the group chain only.
/// * [TipoContextoSalto.destacadas] alone honours
/// [ContextoSalto.uuidsOrdenados] — see that field for why.
List<Emisora> resolverListaContexto({
required ContextoSalto contexto,
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
required List<Emisora> destacadas,
required List<GrupoFavoritos> grupos,
}) {
bool contiene(List<Emisora> lista) =>
lista.any((e) => e.uuid == actual.uuid);
switch (contexto.tipo) {
case TipoContextoSalto.grupoFavoritos:
final grupoId = contexto.grupoFavoritosId;
if (grupoId == null || grupoId == GrupoFavoritos.sinAsignarId) {
return const [];
}
final existe = grupos.any((g) => g.id == grupoId);
final miembros =
favoritos.where((e) => e.grupoFavoritosId == grupoId).toList();
if (existe && miembros.isNotEmpty) {
// A surviving group is honoured as-is. The station does NOT have to
// still be in it — the caller takes the group's first station rather
// than wandering off to another list.
return miembros;
}
// Group deleted (or alive but empty, which offers no station to take):
// widen to all favourites. Unlike the other context types this does not
// require the station to still BE a favourite — the caller takes the
// first one.
return favoritos;
case TipoContextoSalto.favoritos:
return contiene(favoritos) ? favoritos : const [];
case TipoContextoSalto.misEmisoras:
return contiene(misEmisoras) ? misEmisoras : const [];
case TipoContextoSalto.todas:
return contiene(todas) ? todas : const [];
case TipoContextoSalto.destacadas:
// The frozen order is authoritative. `actual` is resolvable from itself
// so a station frozen into the walk from a previous session still
// resolves even when it never belonged to the curated set.
final porUuid = <String, Emisora>{
for (final e in destacadas) e.uuid: e,
actual.uuid: actual,
};
final lista = <Emisora>[
for (final uuid in contexto.uuidsOrdenados)
if (porUuid[uuid] != null) porUuid[uuid]!,
];
return lista.any((e) => e.uuid == actual.uuid) ? lista : const [];
}
}
/// The free tier's frozen walk order: the curated set in its compiled-in
/// order, with [actual] prepended when it does not belong to it.
///
/// Prepending rather than dropping keeps both buttons alive for a station left
/// over from a premium session (or from `ultima_emisora_v1`): a walk the
/// playing station is not part of would make `emisoraVecina` return `null` and
/// both buttons would be dead.
List<String> uuidsCongeladosDestacadas({
required Emisora actual,
required List<Emisora> destacadas,
}) => [
if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid,
...destacadas.map((e) => e.uuid),
];