Files
pluriwave/openspec/changes/android-auto-favorite-groups/design.md
T
FreeTLab f368bcc777 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.
2026-07-19 13:42:25 +02:00

6.7 KiB

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: favoritoscarpetasFavoritos, 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

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).