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.
279 lines
11 KiB
Dart
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),
|
|
];
|