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 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 uuidsOrdenados; Map 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 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().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 a, List 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 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 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.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 resolverListaContexto({ required ContextoSalto contexto, required Emisora actual, required List favoritos, required List misEmisoras, required List todas, required List destacadas, required List grupos, }) { bool contiene(List 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 = { for (final e in destacadas) e.uuid: e, actual.uuid: actual, }; final lista = [ 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 uuidsCongeladosDestacadas({ required Emisora actual, required List destacadas, }) => [ if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid, ...destacadas.map((e) => e.uuid), ];