# 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:` 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:` **Choice**: Group folders use `grupo:` (e.g. `grupo:grupo_172...`). **Alternatives**: Reuse the bare SQLite id; a numeric index. **Rationale**: The `grupo:` prefix is collision-free against `emisora:` 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: folders └─ sin_asignar stations → emisora: leaves Car ──getChildren(grupo:)─▶ 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 ```dart abstract class FuenteEmisorasAuto { // ...existing favoritos()/misEmisoras()/todas()/porUuid()... Future> grupos(); // cold-safe; []-on-error void actualizarSnapshot({ /* ...existing... */ List? grupos }); } class ConstructorArbolAuto { static const _prefijoGrupo = 'grupo:'; static const _maxGruposPorFavoritos = 50; bool esCarpetaGrupo(String id); // id.startsWith('grupo:') List carpetasFavoritos({ required List grupos, required List favoritos }); // folders + ungrouped leaves List hijosGrupo(String grupoMediaId, { required List favoritos }); // members, sorted+capped MediaItem itemGrupo(GrupoFavoritos g); // id 'grupo:', 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:` 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 `Favoritos` node acceptably (fallback: wrap ungrouped leaves in a folder).