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:
@@ -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.
|
||||
Reference in New Issue
Block a user