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:
2026-07-19 13:42:25 +02:00
parent f9003436ea
commit f368bcc777
11 changed files with 812 additions and 1 deletions
@@ -0,0 +1,122 @@
# 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
```dart
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 `Favoritos` node acceptably (fallback: wrap ungrouped leaves in a folder).