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,79 @@
# Apply Progress: Android Auto Favorite Groups
**Change**: android-auto-favorite-groups
**Mode**: Strict TDD
**Batch**: 1 of 1 (single delivery, no chaining — 291 changed lines, within 280-370 forecast)
## Completed Tasks
All 22 tasks from `tasks.md` are complete (Phase 1 through Phase 5).
- [x] 1.1-1.5 Foundation: snapshot seam + media-id scheme
- [x] 2.1-2.10 Core Implementation: pure tree builders
- [x] 3.1-3.3 Integration/Wiring
- [x] 4.1-4.3 Testing/Verification
- [x] 5.1 Cleanup (doc comments)
## Files Changed
| File | Action | What Was Done |
|------|--------|----------------|
| `lib/servicios/navegacion_auto.dart` | Modified | Added `grupos()` to `FuenteEmisorasAuto` abstract + `grupos` param on `actualizarSnapshot`; `FuenteEmisorasAutoLocal.grupos()` (snapshot-or-SQLite, try/catch→`[]`, mirrors `favoritos()`); `ConstructorArbolAuto`: `_prefijoGrupo`, `_maxGruposPorFavoritos`, `esCarpetaGrupo`, `itemGrupo`, `carpetasFavoritos`, `hijosGrupo`. |
| `lib/servicios/servicio_audio.dart` | Modified | `getChildren`: two new thin branches (`idFavoritos``carpetasFavoritos`, `esCarpetaGrupo``hijosGrupo`); removed now-unreachable `idFavoritos` case from `_listaParaCarpeta`'s switch. |
| `lib/estado/estado_radio.dart` | Modified | `cargarGruposFavoritos()` now pushes `_fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos)`, mirroring the existing `favoritos:` push. |
| `test/servicios/navegacion_auto_test.dart` | Modified | New groups: `esCarpetaGrupo`, `itemGrupo`, `carpetasFavoritos` (regression parity, custom groups, empty-group omission, >50 cap), `hijosGrupo`, `resolver` collision-safety; `_FakeFuenteEmisorasAuto` updated to implement `grupos()`. |
| `test/estado/estado_radio_test.dart` | Modified | `_FuenteEmisorasAutoEspia` updated to implement `grupos()` and capture `ultimoGrupos`; existing live-snapshot test extended with an assertion that `cargarGruposFavoritos()` pushes the group snapshot. |
## TDD Cycle Evidence
| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR |
|------|-----------|-------|------------|-----|-------|-------------|----------|
| 1.1-1.2 (`esCarpetaGrupo`) | `navegacion_auto_test.dart` | Unit | 26/26 baseline | Written (compile-fail, symbol absent) | Passed | 4 cases (grupo:/favoritos/emisora:/empty) | Clean |
| 1.3-1.5 (seam extension) | `navegacion_auto_test.dart` | Unit | same run | Written (fake didn't compile) | Passed | N/A — structural interface extension | Clean |
| 2.1-2.2 (`itemGrupo`) | `navegacion_auto_test.dart` | Unit | same run | Written | Passed | Single scenario (spec defines one shape) | Clean |
| 2.3-2.7 (`carpetasFavoritos`) | `navegacion_auto_test.dart` | Unit | same run | Written (4 tests: regression parity, custom groups, empty omission, >50 cap) | Passed | 4 cases forced real filter/take/append logic (not fakeable with hardcoded return) | Clean |
| 2.8-2.9 (`hijosGrupo`) | `navegacion_auto_test.dart` | Unit | same run | Written (60-station cap+sort case, unknown-id case) | Passed | 2 cases (cap+sort vs. empty-for-unknown) | Clean |
| 2.10 (`resolver` collision) | `navegacion_auto_test.dart` | Unit | same run | Written | Passed | Single scenario (collision-free assertion) | Clean |
| 3.1-3.2 (`servicio_audio.dart` wiring) | N/A (zero-coverage dispatch layer, confirmed by 2 prior archived verify-reports) | N/A | N/A | N/A — thin dispatch, no test file exists for this layer per design precedent | Manual static review | N/A | Manual review: no unused symbols, no leftover `idFavoritos` switch case, prefixes non-colliding |
| 3.3 (`estado_radio.dart` push) | `estado_radio_test.dart` | Unit | 21/21 baseline (pre-change: file needed a compile fix for the new interface member regardless) | Written (new assertion on existing test) | Passed | N/A — mirrors existing `favoritos:` push pattern 1:1 | Clean |
### Test Summary
- **Total tests written**: 8 new test groups / 10 new test cases in `navegacion_auto_test.dart` + 1 new assertion in `estado_radio_test.dart`
- **Total tests passing**: 54/54 (33/33 `navegacion_auto_test.dart`, 21/21 `estado_radio_test.dart`) — corrected during verify, apply agent's original count of 55/55 (34/34) was inaccurate
- **Layers used**: Unit (all)
- **Approval tests** (refactoring): None — no refactoring-of-existing-behavior tasks; task 5.1 was pure doc-comment addition, verified via full re-run
- **Pure functions created**: `esCarpetaGrupo`, `itemGrupo`, `carpetasFavoritos`, `hijosGrupo` (all pure, no side effects, fully unit-tested)
## Deviations from Design
None — implementation matches design.md exactly, including:
- `grupo:<id>` media-id scheme, collision-free against `emisora:<uuid>` (verified via test)
- Empty custom groups hidden from the car tree (Design Decision "Empty groups hidden")
- `sin_asignar` stations stay as direct leaves at `Favoritos` root, never their own folder (Design Decision "Ungrouped favorites stay as direct leaves")
- `carpetasFavoritos` reuses the existing `hijos(idFavoritos, emisoras: ...)` path for `sin_asignar` leaves — confirmed via the regression-parity test (2.3) which asserts byte-identical output to `hijos()` when no custom groups exist. This directly satisfies the orchestrator's flagged risk: the no-custom-groups case is not reimplemented, it delegates to the exact same code path.
- `_maxGruposPorFavoritos = 50` kept as a separate constant from `_maxItemsPorCarpeta`, per design's tunability rationale
- Phone order preserved (`orden ASC, nombre ASC` from `obtenerGrupos()`, no re-sort applied)
- Handler dispatch (`servicio_audio.dart`) kept to two thin branches; all real logic lives in pure, tested functions in `navegacion_auto.dart`
`flutter build`/`flutter analyze`/`flutter gen-l10n` were NOT run per environment constraint (hangs) — task 4.3 [DEVIATION], same precedent as `openspec/changes/archive/2026-07-16-android-auto-media/tasks.md`. Manual static review completed: no unused symbols, prefix collision-safety verified by test (2.10), no leftover references to the removed `_listaParaCarpeta` switch case.
## Issues Found
One implementation detail not explicitly called out in tasks.md: `test/estado/estado_radio_test.dart`'s `_FuenteEmisorasAutoEspia` fake implements `FuenteEmisorasAuto` and would have failed to compile once the interface gained `grupos()`. Fixed as part of task 4.2's "run any estado_radio/servicio_audio test files if they exist" — updated the fake and added a real assertion (not just a compile fix) proving `cargarGruposFavoritos()` pushes the snapshot correctly, consistent with the file's existing test style for the sibling `favoritos:`/`misEmisoras:`/`todas:` pushes.
`MediaItem.playable` is nullable (`bool?`) in the `audio_service` package — the `>50 groups truncated` test originally used `!i.playable` which failed to compile; fixed to `i.playable != true`.
## Remaining Tasks
None. All 22 tasks complete.
## Workload / PR Boundary
- Mode: single PR (Review Workload Forecast: Medium risk, no chaining recommended, `Decision needed before apply: No`)
- Current work unit: N/A — full change delivered in one batch
- Boundary: starts from zero prior apply-progress, ends with all 22 tasks complete and 54/54 targeted tests green
- Estimated review budget impact: 291 changed lines (5 files: 3 lib, 2 test) — within the 280-370 forecast, under the 400-line budget
## Status
22/22 tasks complete. Ready for sdd-verify.
@@ -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).
@@ -0,0 +1,65 @@
# Proposal: Android Auto Favorite Groups
## Intent
The Android Auto browse tree exposes only 3 flat folders (Favoritos, Todas, Mis Emisoras), making stations hard to find while driving. The user asked for favorites grouped by category. This is NOT a new feature: favorite groups already exist end-to-end (`GrupoFavoritos` model, SQLite `grupos_favoritos` CRUD, `Emisora.grupoFavoritosId`, `EstadoRadio.gruposFavoritos`, and the phone UI `_GrupoFavoritosPanel`). The only gap is that this categorization was never pushed into the Auto-facing data source. Success = the same grouping the phone already shows becomes reachable in the car, driver-distraction-safe.
## Scope
### In Scope
- Extend `FuenteEmisorasAuto` / `actualizarSnapshot()` with a `grupos` parameter so favorite groups and their member stations reach the Auto data source.
- Wire `EstadoRadio` to push `gruposFavoritos` into `_fuenteAuto` on snapshot updates.
- Extend `ConstructorArbolAuto` to render favorite groups as sub-folders reachable from the existing `Favoritos` folder, with a new `grupo:<id>` media-id scheme.
- Keep `Todas` and `Mis Emisoras` unchanged (conservative reorg).
- Flag the empty-group and folder-count car-UX decisions for `sdd-design` to resolve.
### Out of Scope
- Automatic grouping by pais/idioma/codec (explore Approach 2).
- EQ-from-car (separate `android-auto-eq-presets` change — deliver AFTER this).
- Any change to group CRUD, phone UI, or the SQLite schema.
- Android for Cars App Library / custom templates.
## Capabilities
### New Capabilities
- None.
### Modified Capabilities
- `android-auto-media`: browse tree gains favorite-group sub-folders under `Favoritos` and a `grupo:<id>` browsable media-id. NOTE: this spec is mid-archive by a concurrent process — `sdd-spec` must confirm its final location before writing the delta.
## Approach
Follow explore Investigation 1, Approach 1 (data-model reuse, not new categorization). Add `grupos()` + a `grupos` snapshot field to the Auto data source, mirroring the existing `favoritos`/`misEmisoras`/`todas` seams. `ConstructorArbolAuto.raiz()` keeps 3 root folders; `hijos(idFavoritos)` returns group sub-folders; `hijos(grupo:<id>)` returns that group's stations (reusing the existing sort + 50-item cap). Playable leaves stay `emisora:<uuid>`. Direct mirror of `pantalla_favoritos.dart` parity.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `lib/servicios/navegacion_auto.dart` | Modified | `FuenteEmisorasAuto` interface + `ConstructorArbolAuto` tree/media-ids |
| `lib/estado/estado_radio.dart` | Modified | Push `gruposFavoritos` into Auto snapshot |
| `lib/modelos/grupo_favoritos.dart`, `lib/servicios/servicio_favoritos.dart` | Read-only reuse | Existing group data/CRUD |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Shared-file merge conflict with `android-auto-eq-presets` | Med | Deliver this change FIRST, sequentially |
| Path collision with mid-archive `android-auto-media` specs | Med | Do not touch those paths; `sdd-spec` confirms spec location |
| Driver distraction from too many / empty group folders | Med | Defer empty-group + folder-count decision to `sdd-design` |
| Browse depth increases (root -> group -> stations) | Low | Keep root flat; only `Favoritos` gains one level |
## Rollback Plan
Additive change. Revert the `grupos` parameter, the `EstadoRadio` snapshot wiring, and the `ConstructorArbolAuto` group-folder branch. The pre-existing 3-folder tree is restored; group data in SQLite and phone UI are untouched.
## Dependencies
- None external. Depends only on already-shipped group data model (parent `android-auto-media` change).
## Success Criteria
- [ ] `actualizarSnapshot()` accepts and stores favorite groups.
- [ ] Auto browse tree shows one sub-folder per surfaced group under `Favoritos`.
- [ ] Selecting a group folder lists its member stations; selecting a station plays it.
- [ ] `Todas` and `Mis Emisoras` behavior unchanged.
- [ ] Empty-group and folder-count UX resolved as explicit design decisions.
@@ -0,0 +1,90 @@
# Delta for android-auto-media
## ADDED Requirements
### Requirement: Favorite Group Sub-Folders
The Android Auto browse tree MUST expose favorite groups (`GrupoFavoritos`, as already modeled by `Emisora.grupoFavoritosId` and surfaced by `EstadoRadio.gruposFavoritos`) as browsable, non-playable sub-folders reachable from the existing `Favoritos` folder, without altering the 3 root folders (`Favoritos`, `Todas las emisoras`, `Mis emisoras`).
#### Scenario: Car requests the Favoritos folder and groups exist
- GIVEN the user has one or more favorite groups with at least one member station each
- WHEN `getChildren` is called with the `Favoritos` folder id
- THEN it returns one non-playable folder `MediaItem` per surfaced group, in addition to (or instead of, per the design's structural decision) any ungrouped favorite stations
- AND each group folder's id follows a browsable `grupo:<id>` scheme distinct from the `emisora:<uuid>` playable-item scheme
#### Scenario: Car requests a group folder's stations
- GIVEN a favorite group folder with id `grupo:<id>` was returned under `Favoritos`
- WHEN `getChildren` is called with that `grupo:<id>` folder id
- THEN it returns the playable `MediaItem`s for exactly the stations whose `Emisora.grupoFavoritosId` matches `<id>`
- AND those items are sorted and capped using the same ordering and item-count rules already applied to the other folders (`ordenarEmisoras`, 50-item cap)
- AND selecting one of those items plays the corresponding station via the existing `emisora:<uuid>` playback path, unchanged
#### Scenario: Car requests an unknown or stale group id
- GIVEN a `grupo:<id>` id that does not match any group known to the current snapshot
- WHEN `getChildren` is called with that id
- THEN it returns an empty list, not an error
### Requirement: Empty Favorite Group Handling
The system MUST produce a browsable tree that never presents a user-selectable folder promising content it cannot deliver: for any favorite group with zero member stations, the tree MUST either omit that group's folder from `Favoritos`'s children, or include it and return an empty (not erroring) child list when browsed. The specific choice between omitting empty-group folders and showing-but-empty, and any related folder-count/flat-vs-nested structural decision for `Favoritos`, is deferred to `sdd-design`; whichever mechanism design selects MUST satisfy both scenarios below.
#### Scenario: Empty group folder is browsed (if shown)
- GIVEN a favorite group has zero member stations and the design's chosen mechanism surfaces it as a folder under `Favoritos`
- WHEN `getChildren` is called with that group's `grupo:<id>`
- THEN it returns an empty list, not an error
#### Scenario: No user-facing dead end
- GIVEN the full set of favorite groups, including any empty ones
- WHEN the `Favoritos` folder is browsed and then each of its returned children is browsed
- THEN no returned folder child ever throws, hangs, or surfaces a driver-facing error state
- AND the car head unit's total folder/item count presented under `Favoritos` remains within the driver-distraction-safe bounds design establishes
## MODIFIED Requirements
### Requirement: Browsable Media Tree
`getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras) containing playable station items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders (see "Favorite Group Sub-Folders"); `Todas las emisoras` and `Mis emisoras` remain flat, unchanged by this capability.
(Previously: `Favoritos` was a flat folder of playable station items only, with no sub-folder nesting.)
#### Scenario: Car requests the root
- GIVEN the car head unit connects and requests the root (`AudioService.browsableRootId`)
- WHEN `getChildren` is called with the root id
- THEN it returns three folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras), each with `playable: false`
#### Scenario: Car requests a folder with no stations
- GIVEN the user has zero favorite stations
- WHEN `getChildren` is called with the Favoritos folder id
- THEN it returns an empty list, not an error
#### Scenario: Browse requested before app state is loaded
- GIVEN the audio handler starts cold and station/favorites Provider state has not finished loading
- WHEN `getChildren` is called (root or any folder)
- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service
#### Scenario: Station has known codec and bitrate
- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known (non-null)
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL contain a human-readable quality hint combining bitrate and codec (e.g. "128 kbps · MP3")
#### Scenario: Station has unknown codec or bitrate
- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` (or both) is null/unknown
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL omit the quality hint gracefully (no subtitle, or a subtitle with no quality fragment)
- AND the subtitle MUST NOT render literal placeholder text such as "null kbps" or "null · null"
#### Scenario: Ungrouped station appears exactly as before (regression guard)
- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId` (`'sin_asignar'`, the default when no group is assigned)
- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed
- THEN that station appears as a playable `emisora:<uuid>` item in exactly the same folder(s), position (subject to existing sort rules), title, art, and subtitle as it did before favorite-group folders were introduced
- AND its presence and shape are unaffected by the existence, emptiness, or content of any favorite group
@@ -0,0 +1,78 @@
# Tasks: Android Auto Favorite Groups
Strict TDD active for Dart layers. Behavioral task = RED (failing test) -> GREEN
(minimal impl) -> REFACTOR. `flutter build`/`flutter run`/`flutter analyze`/
`flutter gen-l10n` MUST NOT be executed in this environment (hang) — marked
**[DEVIATION]**, same precedent as `openspec/changes/archive/2026-07-19-auto-media-art-quality/tasks.md`.
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | 280-370 |
| 400-line budget risk | Medium |
| Chained PRs recommended | No |
| Suggested split | Single PR; fallback 2-way split if apply exceeds budget: PR 1 = Phase 1-2 (builders+tests), PR 2 = Phase 3-5 (wiring+cleanup) |
| Delivery strategy | ask-on-risk (default, not overridden by caller) |
| Chain strategy | pending |
Decision needed before apply: No
Chained PRs recommended: No
Chain strategy: pending
400-line budget risk: Medium
### Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|------|------|-----------|-------|
| 1 | Snapshot seam + pure builders (Phase 1-2) + tests | PR 1 | Independent of dispatch wiring; fully unit-testable |
| 2 | Handler/state wiring (Phase 3-5) | PR 2 (fallback only) | Depends on Unit 1; only split out if diff runs over 400 |
## Baseline (verified against live code, not spec/design prose)
- `lib/servicios/navegacion_auto.dart:86-107` `FuenteEmisorasAuto` — no `grupos()`; `actualizarSnapshot` lacks a `grupos` param.
- `lib/servicios/navegacion_auto.dart:112-185` `ConstructorArbolAuto` — no group consts/builders; no `GrupoFavoritos` import.
- `lib/servicios/navegacion_auto.dart:229-323` `FuenteEmisorasAutoLocal` — no grupos snapshot/read; `favoritos()` try/catch pattern at 257-268 is the template to mirror.
- `lib/servicios/servicio_audio.dart:730-796` `getChildren`/`_listaParaCarpeta``favoritos` id routes through flat `hijos()`; no `grupo:` branch.
- `lib/estado/estado_radio.dart:346-349` `cargarGruposFavoritos()` — no `_fuenteAuto?.actualizarSnapshot(grupos: ...)` push (sibling `cargarFavoritos()` at line 342 does push).
- `lib/servicios/servicio_favoritos.dart:166-173` `obtenerGrupos()` confirmed `orden ASC, nombre ASC`; `sin_asignar` auto-inserted + protected (lines 83, 142-153).
- `lib/pantallas/pantalla_favoritos.dart:58-99` confirms the UI iterates `grupos` in query order with no re-sort — design's "phone order" claim holds.
- **Contract gap found in design.md**: `carpetasFavoritos` MUST route `sin_asignar` leaves through the existing `hijos(idFavoritos, emisoras: ...)` path (identical sort+cap+map) to guarantee byte-identical output when no custom groups exist. design.md's Interfaces section doesn't spell this out explicitly, but it's required by the regression scenario and Testing Strategy row 1 — tasks below make it explicit.
- No existing unit tests exercise `FuenteEmisorasAutoLocal` directly (only the hand-written `_FakeFuenteEmisorasAuto`); `grupos()` follows the same untested-Local-impl precedent as sibling `favoritos()`/`misEmisoras()` — no new SQLite-mocking test added for it.
## Phase 1: Foundation — snapshot seam + media-id scheme
- [x] 1.1 [RED] `navegacion_auto_test.dart`: `esCarpetaGrupo` test table — `'grupo:g1'`→true, `'favoritos'`/`'emisora:x'`/`''`→false. *(Design "media-id scheme")*
- [x] 1.2 [GREEN] `navegacion_auto.dart`: add `_prefijoGrupo='grupo:'`, `_maxGruposPorFavoritos=50` consts; `esCarpetaGrupo(id) => id.startsWith(_prefijoGrupo)`.
- [x] 1.3 `navegacion_auto.dart`: add `import '../modelos/grupo_favoritos.dart'`; extend `FuenteEmisorasAuto.grupos()` (abstract) + `actualizarSnapshot({..., List<GrupoFavoritos>? grupos})`.
- [x] 1.4 `navegacion_auto.dart`: `FuenteEmisorasAutoLocal` — add `_snapshotGrupos`, `grupos()` override (snapshot-or-`_favoritosServicio.obtenerGrupos()`, try/catch→`[]`, mirrors lines 257-268), wire `grupos` param in `actualizarSnapshot`.
- [x] 1.5 `navegacion_auto_test.dart`: update `_FakeFuenteEmisorasAuto` — implement `grupos()` (default `const []`) + `actualizarSnapshot` grupos param, so existing tests keep compiling.
## Phase 2: Core Implementation — pure tree builders
- [x] 2.1 [RED] `itemGrupo` test — id `'grupo:<id>'`, `playable:false`, `title == g.nombre`.
- [x] 2.2 [GREEN] `navegacion_auto.dart`: `itemGrupo(g) => _carpeta('$_prefijoGrupo${g.id}', g.nombre)` (reuses existing `_carpeta` helper, line 141).
- [x] 2.3 [RED] `carpetasFavoritos` regression: `grupos=[sin_asignar]`, no custom groups → output equals current `hijos(idFavoritos, emisoras: favoritos)` (same items, same order). *Most important test in this change — Spec "Ungrouped station appears exactly as before".*
- [x] 2.4 [RED] `carpetasFavoritos`: 1+ non-empty custom group → `grupo:<id>` folders (playable:false, source order) + `sin_asignar` leaves, folders first.
- [x] 2.5 [RED] `carpetasFavoritos`: empty custom group omitted from output; `sin_asignar` never becomes its own folder even when present in `grupos`.
- [x] 2.6 [RED] `carpetasFavoritos`: >50 eligible non-empty groups truncated to 50.
- [x] 2.7 [GREEN] implement `carpetasFavoritos({grupos, favoritos})`: filter `grupos` to non-`esSinAsignar` with ≥1 member (via `favoritos.any`), `.take(_maxGruposPorFavoritos).map(itemGrupo)`; append `hijos(idFavoritos, emisoras: favoritos.where(sin_asignar))`. Satisfies 2.3-2.6.
- [x] 2.8 [RED] `hijosGrupo`: filters by `grupoFavoritosId`, reuses `ordenarEmisoras`+50 cap (mirror existing cap test ~line 334); unknown/stale/malformed id → `[]`.
- [x] 2.9 [GREEN] implement `hijosGrupo(grupoMediaId, {favoritos})`: strip `_prefijoGrupo` defensively (mirror `resolver`), filter, sort via `ordenarEmisoras`, `.take(_maxItemsPorCarpeta)`, map `itemEmisora`.
- [x] 2.10 [RED] assert `resolver('grupo:g1', universo) == null` — collision-free vs `emisora:` scheme. *(Spec "Media Item Resolution")*
## Phase 3: Integration / Wiring
- [x] 3.1 `servicio_audio.dart:730-748` `getChildren`: branch `parentMediaId == idFavoritos``carpetasFavoritos(grupos: await fuente.grupos(), favoritos: await fuente.favoritos())`; branch `constructor.esCarpetaGrupo(parentMediaId)``hijosGrupo(parentMediaId, favoritos: await fuente.favoritos())`; keep existing `todas`/`mis_emisoras` path and outer try/catch unchanged.
- [x] 3.2 `servicio_audio.dart:791-795` `_listaParaCarpeta`: remove the now-unreachable `idFavoritos` switch case (handled by 3.1).
- [x] 3.3 `estado_radio.dart:346-349` `cargarGruposFavoritos()`: add `_fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos);` (mirrors line 342's `favoritos:` push).
## Phase 4: Testing / Verification
- [x] 4.1 Run `flutter test test/servicios/navegacion_auto_test.dart` — full green.
- [x] 4.2 Run any `estado_radio`/`servicio_audio` test files if they exist; else note as manual follow-up (no existing suite found for these two files as of this writing). *(Updated: `test/estado/estado_radio_test.dart`'s `_FuenteEmisorasAutoEspia` needed `grupos()` wiring to keep compiling; added an assertion proving the new `actualizarSnapshot(grupos:)` push — full file green, 21/21.)*
- [x] 4.3 [DEVIATION] `flutter build`/`flutter run`/`flutter analyze`/`flutter gen-l10n` — DO NOT RUN in this environment (hangs). Manual static review instead: no unused symbols, `grupo:`/`emisora:` prefixes non-colliding by inspection, no leftover references to the removed `_listaParaCarpeta` case. Real run recommended before merge/CI.
## Phase 5: Cleanup
- [x] 5.1 [REFACTOR] `navegacion_auto.dart`: doc-comment new public members per the file's existing Design-decision-linking style; confirm full test suite still green.
@@ -0,0 +1,88 @@
# Verify Report: android-auto-favorite-groups
**Verdict**: PASS WITH WARNINGS
**Mode**: Strict TDD verify (fresh adversarial pass against live code)
**Date**: 2026-07-19
## Test Execution Evidence
Command actually run (targeted, not full suite, per environment constraint):
```
flutter test test/servicios/navegacion_auto_test.dart test/estado/estado_radio_test.dart --concurrency=1 --timeout=60s
```
Result: All tests passed! 54/54 total.
- test/servicios/navegacion_auto_test.dart: 33/33 passed (verified by both live run indices +0..+32 and grep -c count of test() calls = 33)
- test/estado/estado_radio_test.dart: 21/21 passed (indices +33..+53, grep -c count = 21)
Discrepancy vs apply-progress claim: apply-progress.md states 55/55 (34/34 navegacion_auto_test.dart, 21/21 estado_radio_test.dart). Actual is 54/54 (33/33 + 21/21). Functionally harmless (every test that exists passes), but the reported count is off by one test in navegacion_auto_test.dart. WARNING, not CRITICAL: no missing coverage was found, no scenario is untested, this is a report-accuracy defect only.
## Working Tree State
git status --porcelain confirms only unstaged modifications plus one untracked dir, nothing committed:
```
M lib/estado/estado_radio.dart
M lib/servicios/navegacion_auto.dart
M lib/servicios/servicio_audio.dart
M test/estado/estado_radio_test.dart
M test/servicios/navegacion_auto_test.dart
?? openspec/changes/android-auto-favorite-groups/
```
git diff --stat on the 5 changed files: 290 insertions(+), 1 deletion(-) = 291 changed lines, matching apply-progress's 291-changed-lines claim exactly, and within the tasks.md forecast (280-370, Medium risk, no chaining needed).
## Spec Compliance Matrix
| Spec Requirement / Scenario | Status | Evidence |
|---|---|---|
| Favorite Group Sub-Folders - groups exist under Favoritos | PASS | carpetasFavoritos test "con un grupo personalizado no vacio" (navegacion_auto_test.dart:459-482) |
| grupo:<id> scheme distinct from emisora:<uuid> | PASS | esCarpetaGrupo/resolver both prefix-gated; collision test at line 557-565 (resolver("grupo:g1", ...) returns null) |
| Group folder stations, sorted+capped, playable via existing path | PASS | hijosGrupo reuses ordenarEmisoras + _maxItemsPorCarpeta (50); test at 527-554 |
| Unknown/stale group id returns empty list, not error | PASS | hijosGrupo early-returns const [] on !esCarpetaGrupo, empty id, or empty miembros; tested (line 549-553) |
| Empty Favorite Group Handling - omitted from tree | PASS | carpetasFavoritos filters favoritos.any((e) => e.grupoFavoritosId == g.id); real empty-group fixture test "un grupo personalizado vacio se omite" (484-505) - genuine fixture (zero matching favorites), not an assertion-by-name only |
| No user-facing dead end (empty group browsed) | PASS (by construction) | Empty groups never surfaced as folders (prior row), so the "browsed empty folder" scenario collapses to N/A by design; hijosGrupo also independently returns [] for any unmatched id without throwing |
| Browsable Media Tree (MODIFIED) - root unaffected | PASS | raiz() unchanged, still 3 folders; no test regressions |
| Ungrouped station appears exactly as before (regression guard) | PASS, verified via direct source trace | carpetasFavoritos literally calls hijos(idFavoritos, emisoras: sinAsignar) (navegacion_auto.dart:234) - the same function/call used pre-change, not a parallel reimplementation. Confirmed by reading the function body, not by trusting the test name. Regression test (429-457) additionally asserts id/playable/title equality against hijos()'s own output for the no-custom-groups case. |
| _maxGruposPorFavoritos = 50 cap enforced | PASS | carpetasFavoritos uses .take(_maxGruposPorFavoritos); test with 60 eligible groups asserts exactly 50 folders returned (507-524). Not an exact-boundary (50 vs 51) test, follows the same bulk-of-60 style as the pre-existing hijos 50-cap test (precedent), so treated as SUGGESTION not a gap. |
| Ordering follows orden ASC, nombre ASC | PASS | ServicioFavoritos.obtenerGrupos() SQL: orderBy: "orden ASC, nombre ASC" (servicio_favoritos.dart:166-172); carpetasFavoritos/itemGrupo apply no re-sort, only filter+take+map, phone order preserved by construction |
## Design Coherence
| Design Decision | Implemented? |
|---|---|
| Empty groups hidden from car tree | Yes - filter condition confirmed above |
| Ungrouped favorites stay as direct leaves, never own folder | Yes - esSinAsignar filtered out of carpetas; sin_asignar leaves appended via hijos() |
| grupo:<id> media-id scheme | Yes |
| Separate _maxGruposPorFavoritos constant (not reusing _maxItemsPorCarpeta) | Yes - declared and used independently |
| FuenteEmisorasAuto.grupos() cold-start-safe (try/catch -> []) | Yes - FuenteEmisorasAutoLocal.grupos() mirrors favoritos()'s try/catch pattern exactly |
| EstadoRadio.cargarGruposFavoritos() pushes snapshot | Yes - _fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos) added, mirrors cargarFavoritos()'s pattern |
| Handler dispatch stays thin (2 new branches only) | Yes - servicio_audio.dart getChildren adds exactly 2 branches, delegates all logic to pure ConstructorArbolAuto methods |
## Undocumented Fix Verification (apply-progress "Issues Found")
Claim: test/estado/estado_radio_test.dart's _FuenteEmisorasAutoEspia needed grupos() added to keep compiling, and a real assertion was added (not just a compile stub).
Confirmed real: the spy implements grupos() returning ultimoGrupos ?? const [], records pushes via actualizarSnapshot, and the test at line 588-591 asserts fuenteAuto.ultimoGrupos?.map((g) => g.id) contains GrupoFavoritos.sinAsignarId after estado.inicializar() - a genuine behavioral assertion on the new grupos: push, not a no-op compile fix.
## Task Completion
All 22 tasks in tasks.md are marked [x] and match the code state - verified by direct inspection of the described changes in each of the 5 changed files, not by trusting the checkbox alone.
## Encoding / Literal Scan
Ran a targeted scan of the diff (git diff on the 5 changed files) for mojibake markers (Latin-1-as-UTF-8 sequences, stray replacement char). No hits. Spanish comments and identifiers in the diff are clean UTF-8.
## Issues Found
### CRITICAL
None.
### WARNING
1. Test-count report accuracy: apply-progress.md claims 55/55 (34/34 navegacion_auto_test.dart, 21/21 estado_radio_test.dart). Actual, independently re-run and grep-verified: 54/54 (33/33 + 21/21). No missing test/coverage - purely a documentation-accuracy defect in the apply report. Should be corrected before archive so the archived record is accurate.
### SUGGESTION
1. The _maxGruposPorFavoritos = 50 cap test uses 60 groups (bulk-over-cap), not an exact 50-vs-51 boundary case. Consistent with the pre-existing hijos 50-cap test's own style (same bulk-of-60 precedent), so low priority - but a tighter boundary test would be marginally more rigorous.
2. Design's "Open Questions" item (mixed browsable+playable children rendering acceptably on real car UI) remains unresolved by nature - requires DHU/real-hardware testing, correctly flagged as manual follow-up in both design.md and tasks.md 4.3. Not blocking; carry forward as a known residual risk until manually verified on hardware.
## Final Verdict
PASS WITH WARNINGS - implementation matches spec and design, the most important regression guard (byte-identical Favoritos folder rendering with zero custom groups) is genuinely satisfied via direct code reuse (not a parallel reimplementation), all 54 targeted tests pass under a fresh independent run, working tree is uncommitted as expected, and diff size matches the reported forecast. The only defect found is a cosmetic test-count inaccuracy in apply-progress.md (54 actual vs. 55 claimed) - recommend correcting that number before archive, but it does not block archival of the functional change.