6.7 KiB
Design: Android Auto Favorite Groups
Technical Approach
Add one nesting level under the existing Favoritos folder by extending the same
cold-start-safe seam the shipped code already uses. FuenteEmisorasAuto gains a
grupos() source and a grupos snapshot field mirroring favoritos(); the pure
ConstructorArbolAuto gains group-folder builders. The handler dispatch stays a
thin switch — all new logic lives in pure functions, unit-testable without a car
(the browse dispatch itself remains zero-coverage per both archived verify-reports).
Realises the android-auto-media delta. Phone UI, CRUD, and SQLite are untouched.
Architecture Decisions
Decision: Empty groups hidden from the car tree
Choice: A custom group with zero members is NOT listed under Favoritos.
Alternatives: Show as empty folder (phone-UI parity — _GrupoFavoritosPanel
renders an empty panel with a "no stations" caption).
Rationale: The phone panel is glanceable; an empty tappable folder in the car
is a dead-end interaction that costs a driver attention for nothing. This is the
car-specific reason to diverge. Non-empty parity is preserved.
Decision: Ungrouped favorites stay as direct leaves at the Favoritos root
Choice: Favoritos children = [non-empty custom-group folders] + [sin_asignar
stations as playable emisora:<uuid> leaves]. The sin_asignar group is NOT
rendered as its own folder.
Alternatives: Put unassigned stations in a dedicated "Sin asignar" folder.
Rationale: Every favorite defaults to grupo_id = 'sin_asignar' (there is no
null case). With no custom groups (the common case) Favoritos renders exactly
as today — a flat station list, zero regression, zero extra taps. A dedicated
folder would force a redundant tap for that common case. Android Auto supports
mixed browsable+playable children at one node. Regression requirement (never drop
or hide ungrouped favorites) is satisfied: they are always reachable one level up.
Decision: media-id scheme grupo:<id>
Choice: Group folders use grupo:<grupos_favoritos.id> (e.g. grupo:grupo_172...).
Alternatives: Reuse the bare SQLite id; a numeric index.
Rationale: The grupo: prefix is collision-free against emisora:<uuid> leaves
and the bare folder constants (favoritos/todas/mis_emisoras). resolver only
matches emisora:, so a grupo: id is a safe no-op for playback (folders are not
playable). The SQLite id is the stable cross-source key, same as uuid for stations.
Decision: group-folder ordering and cap
Choice: Group folders follow the phone's explicit order (obtenerGrupos() =
orden ASC, nombre ASC). A dedicated _maxGruposPorFavoritos = 50 caps the count;
ungrouped leaves keep the existing _maxItemsPorCarpeta = 50.
Alternatives: Alphabetical/MRU re-sort; mirror _maxItemsPorCarpeta directly.
Rationale: Reusing the user's phone order gives deterministic phone/car parity
with no invented heuristic. A separate named constant (initialised to 50) is used
rather than reusing the station cap because a folder tap has a higher distraction
cost than a station scroll, so the group cap must be tunable independently.
Data Flow
Car ──getChildren(favoritos)──▶ Handler ──▶ carpetasFavoritos(grupos, favoritos) [pure]
├─ non-empty custom groups → grupo:<id> folders
└─ sin_asignar stations → emisora:<uuid> leaves
Car ──getChildren(grupo:<id>)─▶ Handler ──▶ hijosGrupo(id, favoritos) [pure]
EstadoRadio.cargarGruposFavoritos ──actualizarSnapshot(grupos:)──▶ FuenteEmisorasAutoLocal
cold bind ── grupos() ─ try/catch ─▶ ServicioFavoritos.obtenerGrupos() (never throws → [])
File Changes
| File | Action | Description |
|---|---|---|
lib/servicios/navegacion_auto.dart |
Modify | grupos() + grupos snapshot on FuenteEmisorasAuto/Local (try/catch → []); _prefijoGrupo, esCarpetaGrupo, carpetasFavoritos, hijosGrupo, itemGrupo on ConstructorArbolAuto |
lib/servicios/servicio_audio.dart |
Modify | getChildren: favoritos→carpetasFavoritos, grupo:→hijosGrupo; thin branches only |
lib/estado/estado_radio.dart |
Modify | Push actualizarSnapshot(grupos: _gruposFavoritos) in cargarGruposFavoritos() |
test/servicios/navegacion_auto_test.dart |
Modify | Pure-function tests for the new builders |
Interfaces / Contracts
abstract class FuenteEmisorasAuto {
// ...existing favoritos()/misEmisoras()/todas()/porUuid()...
Future<List<GrupoFavoritos>> grupos(); // cold-safe; []-on-error
void actualizarSnapshot({ /* ...existing... */ List<GrupoFavoritos>? grupos });
}
class ConstructorArbolAuto {
static const _prefijoGrupo = 'grupo:';
static const _maxGruposPorFavoritos = 50;
bool esCarpetaGrupo(String id); // id.startsWith('grupo:')
List<MediaItem> carpetasFavoritos({
required List<GrupoFavoritos> grupos,
required List<Emisora> favoritos }); // folders + ungrouped leaves
List<MediaItem> hijosGrupo(String grupoMediaId, {
required List<Emisora> favoritos }); // members, sorted+capped
MediaItem itemGrupo(GrupoFavoritos g); // id 'grupo:<id>', playable:false
}
carpetasFavoritos: skip esSinAsignar and empty custom groups → folders; append
sin_asignar members as itemEmisora leaves; cap folders at 50. hijosGrupo:
filter favorites by grupoFavoritosId == id, reuse ordenarEmisoras + 50 cap;
unknown/empty id → const [].
Testing Strategy
| Layer | What to Test | Approach |
|---|---|---|
| Unit | No custom groups → all favorites as leaves (regression parity) | carpetasFavoritos |
| Unit | Custom groups → grupo:<id> folders (playable:false) + ungrouped leaves, phone order |
carpetasFavoritos |
| Unit | Empty custom group hidden; sin_asignar never a folder |
carpetasFavoritos |
| Unit | >50 groups truncated | carpetasFavoritos |
| Unit | hijosGrupo filters by group, sorts, caps 50; unknown id → [] |
hijosGrupo |
| Unit | grupo: id is no-op in resolver/reproducirPorMediaId (collision-free) |
pure assertions |
| Manual (DHU) | Browse Favoritos → group → play; cold bind non-empty | user-side |
Migration / Rollout
No migration. Additive: revert the grupos seam, the EstadoRadio push line, and
the two handler branches — the 3-folder tree returns, SQLite/phone untouched.
Open Questions
- Confirm the target car UI renders mixed browsable folders + playable leaves at
the
Favoritosnode acceptably (fallback: wrap ungrouped leaves in a folder).