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:
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../estado/orden_emisoras.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
|
||||
@@ -92,6 +93,11 @@ abstract class FuenteEmisorasAuto {
|
||||
Future<List<Emisora>> todas();
|
||||
Future<Emisora?> porUuid(String uuid);
|
||||
|
||||
/// Favorite groups (`GrupoFavoritos`), cold-start safe — mirrors
|
||||
/// [favoritos]'s never-throws contract (Design "Favorite Group
|
||||
/// Sub-Folders").
|
||||
Future<List<GrupoFavoritos>> grupos();
|
||||
|
||||
/// Live-snapshot push (Design "live snapshot the source prefers"):
|
||||
/// `EstadoRadio`, when alive, calls this unconditionally on every
|
||||
/// favorites/custom/populares mutation so a car and phone that are both
|
||||
@@ -103,6 +109,7 @@ abstract class FuenteEmisorasAuto {
|
||||
List<Emisora>? favoritos,
|
||||
List<Emisora>? misEmisoras,
|
||||
List<Emisora>? todas,
|
||||
List<GrupoFavoritos>? grupos,
|
||||
}) {}
|
||||
}
|
||||
|
||||
@@ -120,6 +127,16 @@ class ConstructorArbolAuto {
|
||||
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
||||
static const _maxItemsPorCarpeta = 50;
|
||||
|
||||
/// Favorite-group folder id prefix (Design "media-id scheme"), collision
|
||||
/// free against [_prefijoEmisora] and the bare folder id constants above.
|
||||
static const _prefijoGrupo = 'grupo:';
|
||||
|
||||
/// Separate cap for favorite-group folders under `Favoritos` (Design
|
||||
/// "group-folder ordering and cap"): a folder tap costs more driver
|
||||
/// attention than a station scroll, so this is tunable independently of
|
||||
/// [_maxItemsPorCarpeta].
|
||||
static const _maxGruposPorFavoritos = 50;
|
||||
|
||||
/// Content-style extras (Design "content style", optional polish): list
|
||||
/// (1) for the root's folders, grid (2) for playable station items.
|
||||
static const _contentStyleLista = {
|
||||
@@ -182,6 +199,60 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Whether [id] identifies a favorite-group folder (Design "media-id
|
||||
/// scheme").
|
||||
bool esCarpetaGrupo(String id) => id.startsWith(_prefijoGrupo);
|
||||
|
||||
/// Maps a [GrupoFavoritos] to a non-playable folder `MediaItem` with id
|
||||
/// `grupo:<id>` (Design "media-id scheme").
|
||||
MediaItem itemGrupo(GrupoFavoritos g) =>
|
||||
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
|
||||
|
||||
/// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
|
||||
/// direct leaves at the Favoritos root"): non-empty custom-group folders
|
||||
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by
|
||||
/// `sin_asignar` stations mapped through the existing [hijos] path so the
|
||||
/// no-custom-groups case is byte-identical to the pre-groups tree
|
||||
/// (regression guard — Spec "Ungrouped station appears exactly as
|
||||
/// before"). Empty custom groups are omitted (Design "Empty groups hidden
|
||||
/// from the car tree"); the `sin_asignar` pseudo-group is never rendered
|
||||
/// as its own folder.
|
||||
List<MediaItem> carpetasFavoritos({
|
||||
required List<GrupoFavoritos> grupos,
|
||||
required List<Emisora> favoritos,
|
||||
}) {
|
||||
final carpetas = grupos
|
||||
.where((g) => !g.esSinAsignar)
|
||||
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
|
||||
.take(_maxGruposPorFavoritos)
|
||||
.map(itemGrupo)
|
||||
.toList();
|
||||
final sinAsignar = favoritos
|
||||
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
|
||||
.toList();
|
||||
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
|
||||
}
|
||||
|
||||
/// Members of the favorite group identified by [grupoMediaId] (a
|
||||
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
|
||||
/// requests a group folder's stations"). An unknown/stale/malformed id
|
||||
/// returns an empty list instead of throwing (Spec "Car requests an
|
||||
/// unknown or stale group id").
|
||||
List<MediaItem> hijosGrupo(
|
||||
String grupoMediaId, {
|
||||
required List<Emisora> favoritos,
|
||||
}) {
|
||||
if (!esCarpetaGrupo(grupoMediaId)) return const [];
|
||||
final id = grupoMediaId.substring(_prefijoGrupo.length);
|
||||
if (id.isEmpty) return const [];
|
||||
final miembros = favoritos
|
||||
.where((e) => e.grupoFavoritosId == id)
|
||||
.toList();
|
||||
if (miembros.isEmpty) return const [];
|
||||
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
|
||||
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
|
||||
@@ -239,6 +310,7 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
List<Emisora>? _snapshotFavoritos;
|
||||
List<Emisora>? _snapshotMisEmisoras;
|
||||
List<Emisora>? _snapshotTodas;
|
||||
List<GrupoFavoritos>? _snapshotGrupos;
|
||||
|
||||
/// Overrides the next reads with `EstadoRadio`'s live in-memory lists
|
||||
/// (Design "live snapshot the source prefers"). Passing `null` for a
|
||||
@@ -248,10 +320,12 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
List<Emisora>? favoritos,
|
||||
List<Emisora>? misEmisoras,
|
||||
List<Emisora>? todas,
|
||||
List<GrupoFavoritos>? grupos,
|
||||
}) {
|
||||
if (favoritos != null) _snapshotFavoritos = favoritos;
|
||||
if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras;
|
||||
if (todas != null) _snapshotTodas = todas;
|
||||
if (grupos != null) _snapshotGrupos = grupos;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -267,6 +341,19 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<GrupoFavoritos>> grupos() async {
|
||||
final snapshot = _snapshotGrupos;
|
||||
if (snapshot != null) return snapshot;
|
||||
try {
|
||||
return await _favoritosServicio.obtenerGrupos();
|
||||
} catch (_) {
|
||||
// Cold-start safety (Spec "Browse requested before app state is
|
||||
// loaded"): never throw out of a browse call.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> misEmisoras() async {
|
||||
final snapshot = _snapshotMisEmisoras;
|
||||
|
||||
Reference in New Issue
Block a user