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.
123 lines
6.7 KiB
Markdown
123 lines
6.7 KiB
Markdown
# 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).
|