feat(auto): equalizer folder in the browse tree, one toggle on playback
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m14s

On-device feedback: two identical icons on the car's now-playing screen,
one of which looked dead. It worked -- but head units render custom
actions icon-first, so cycling six presets behind one static glyph was
invisible.

A monochrome icon cannot encode which of six presets is active. Android
Auto separates the idioms deliberately: custom actions for stateless
toggles, browsable lists for choosing among options.

- Playback screen keeps one action: equalizer on/off, state-aware icons
- New Ecualizador folder lists Desactivar plus the six presets by name,
  active one marked
- The preset-cycling action and its drawable are removed

Supersedes the redesign's no-equalizer-folder rule, which predated
knowing custom actions do not surface state in a car.

# Conflicts:
#	lib/l10n/app_ar.arb
#	lib/l10n/app_bn.arb
#	lib/l10n/app_de.arb
#	lib/l10n/app_en.arb
#	lib/l10n/app_es.arb
#	lib/l10n/app_fr.arb
#	lib/l10n/app_hi.arb
#	lib/l10n/app_id.arb
#	lib/l10n/app_it.arb
#	lib/l10n/app_ja.arb
#	lib/l10n/app_pt.arb
#	lib/l10n/app_ru.arb
#	lib/l10n/app_zh.arb
This commit is contained in:
2026-07-31 19:37:14 +02:00
34 changed files with 2817 additions and 2329 deletions
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
@@ -19,14 +20,8 @@ void main() {
group('presetSiguiente (item 4 — cycling presets)', () {
test('advances to the next preset in order', () {
expect(
presetSiguiente(PresetEcualizador.flat),
PresetEcualizador.rock,
);
expect(
presetSiguiente(PresetEcualizador.rock),
PresetEcualizador.pop,
);
expect(presetSiguiente(PresetEcualizador.flat), PresetEcualizador.rock);
expect(presetSiguiente(PresetEcualizador.rock), PresetEcualizador.pop);
});
test('wraps around after the last preset', () {
@@ -36,21 +31,15 @@ void main() {
);
});
test(
'an unknown/custom preset (e.g. a user-tweaked "Personalizado" band '
'set) starts from the FIRST preset instead of throwing',
() {
final personalizado = PresetEcualizador(
nombre: 'Personalizado',
bandas: [1.0, 2.0, 3.0, 4.0, 5.0],
);
test('an unknown/custom preset (e.g. a user-tweaked "Personalizado" band '
'set) starts from the FIRST preset instead of throwing', () {
final personalizado = PresetEcualizador(
nombre: 'Personalizado',
bandas: [1.0, 2.0, 3.0, 4.0, 5.0],
);
expect(
presetSiguiente(personalizado),
PresetEcualizador.presets.first,
);
},
);
expect(presetSiguiente(personalizado), PresetEcualizador.presets.first);
});
test('respects an injected presets list instead of the default 6', () {
final propios = [PresetEcualizador.jazz, PresetEcualizador.voz];
@@ -84,7 +73,10 @@ void main() {
});
test('an unrecognized name falls through verbatim', () {
expect(nombrePresetVisible(l10n, 'Mi Preset Guardado'), 'Mi Preset Guardado');
expect(
nombrePresetVisible(l10n, 'Mi Preset Guardado'),
'Mi Preset Guardado',
);
});
});
@@ -93,35 +85,32 @@ void main() {
final controles = controlesEcualizadorPersonalizados(
disponible: false,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
expect(controles, isEmpty);
});
test(
'exactly 2 custom actions when available: on/off toggle + preset '
'cycle -- Android Auto shows a limited number of custom actions, so '
'this is deliberately NOT one action per preset',
() {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.rock,
l10n: l10n,
);
test('exactly 1 custom action when available: the on/off toggle -- '
'decision `auto/ecualizador-diseno` REMOVES the preset-cycling '
'action that used to sit alongside it; preset selection now lives '
'in the "Ecualizador" browsable folder instead (see '
'`itemsEcualizadorAuto`)', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
l10n: l10n,
);
expect(controles, hasLength(2));
expect(controles.every((c) => c.action == MediaAction.custom), isTrue);
},
);
expect(controles, hasLength(1));
expect(controles.single.action, MediaAction.custom);
expect(controles.single.customAction?.name, accionEqToggle);
});
test('toggle label reflects ON -> shows "disable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final toggle = controles.firstWhere(
@@ -135,7 +124,6 @@ void main() {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: false,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final toggle = controles.firstWhere(
@@ -145,45 +133,68 @@ void main() {
expect(toggle.label, l10n.eqCustomActionEnableLabel);
});
test('preset-cycle label shows the CURRENT preset localized name', () {
final controles = controlesEcualizadorPersonalizados(
test('toggle icon reflects EQ state: ON uses ic_auto_eq_on, OFF uses '
'ic_auto_eq_off -- a car head unit foregrounds the icon over the '
'label, so the icon itself must change, not just the text', () {
final activado = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.jazz,
l10n: l10n,
);
final ciclo = controles.firstWhere(
(c) => c.customAction?.name == accionEqPresetSiguiente,
);
expect(
ciclo.label,
l10n.eqCustomActionPresetLabel(l10n.equalizerPresetJazz),
);
});
test('both actions reuse the bundled notification drawable (zero new '
'native assets)', () {
final controles = controlesEcualizadorPersonalizados(
).firstWhere((c) => c.customAction?.name == accionEqToggle);
final desactivado = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.flat,
activo: false,
l10n: l10n,
);
).firstWhere((c) => c.customAction?.name == accionEqToggle);
expect(
controles.every((c) => c.androidIcon == 'drawable/ic_stat_pluriwave'),
isTrue,
);
expect(activado.androidIcon, 'drawable/ic_auto_eq_on');
expect(desactivado.androidIcon, 'drawable/ic_auto_eq_off');
});
});
group('action name constants (item 4 — collision-free with car-tree ids)', () {
test('names are non-empty, distinct, and do not collide with any '
'existing browse-tree media-id prefix', () {
expect(accionEqToggle, isNotEmpty);
expect(accionEqPresetSiguiente, isNotEmpty);
expect(accionEqToggle, isNot(equals(accionEqPresetSiguiente)));
group(
'action name constants (item 4 -- collision-free with car-tree ids)',
() {
test('accionEqToggle is non-empty and does not collide with any '
'existing browse-tree media-id prefix -- accionEqPresetSiguiente '
'(decision `auto/ecualizador-diseno`: removed, superseded by the '
'"Ecualizador" browsable folder) no longer exists as a symbol at '
'all, which this file compiling proves on its own', () {
expect(accionEqToggle, isNotEmpty);
});
},
);
group('equalizer drawable assets on disk (on-device feedback follow-up: the '
'two custom actions used to share one drawable and were visually '
'indistinguishable)', () {
test('ic_auto_eq_on and ic_auto_eq_off exist under '
'android/app/src/main/res/drawable/ -- a missing drawable is not a '
'build error, it silently renders blank/default on the head unit, '
'so this is the only safety net that would have caught the '
'original duplication. ic_auto_eq_preset is deliberately NOT '
'checked here anymore -- decision `auto/ecualizador-diseno` '
'removes the preset-cycling action and its drawable', () {
for (final nombre in ['ic_auto_eq_on', 'ic_auto_eq_off']) {
final archivo = File('android/app/src/main/res/drawable/$nombre.xml');
expect(
archivo.existsSync(),
isTrue,
reason:
'$nombre.xml must exist under '
'android/app/src/main/res/drawable/',
);
}
expect(
File(
'android/app/src/main/res/drawable/ic_auto_eq_preset.xml',
).existsSync(),
isFalse,
reason:
'ic_auto_eq_preset.xml must be REMOVED -- decision '
'`auto/ecualizador-diseno` retires the preset-cycling '
'custom action it belonged to',
);
});
});
}
@@ -0,0 +1,123 @@
import 'dart:ui' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Decision `auto/ecualizador-diseno` -- the "Ecualizador" browsable
/// folder's item-building (`itemsEcualizadorAuto`), the pure,
/// `AppLocalizations`-dependent half of the folder feature. Lives in a
/// dedicated file, separate from `servicio_audio_eq_custom_actions_test.dart`
/// (which covers the now-playing screen's on/off toggle) because this is a
/// different car UI surface: a browsable folder, not a custom action.
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
group('itemsEcualizadorAuto (decision `auto/ecualizador-diseno`)', () {
test('devuelve exactamente 7 items: Desactivar primero, luego los 6 '
'presets de fábrica, todos playable', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
expect(items, hasLength(7));
expect(items.first.id, ConstructorArbolAuto.idDesactivarEq);
for (final item in items) {
expect(item.playable, isTrue);
}
});
test('los 6 presets aparecen en el mismo orden que '
'PresetEcualizador.presets, cada uno con su id eq_preset:<nombre>', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final builder = ConstructorArbolAuto();
final idsPresets = items.skip(1).map((i) => i.id).toList();
final idsEsperados =
PresetEcualizador.presets
.map((p) => builder.idPresetEq(p.nombre))
.toList();
expect(idsPresets, idsEsperados);
});
test('los nombres de preset están localizados vía nombrePresetVisible, '
'no crudos', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final builder = ConstructorArbolAuto();
final rock = items.firstWhere(
(i) => i.id == builder.idPresetEq(PresetEcualizador.rock.nombre),
);
expect(rock.title, contains(nombrePresetVisible(l10n, 'Rock')));
});
test('con el ecualizador ACTIVO, el preset actual queda marcado y '
'Desactivar NO', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.jazz,
l10n: l10n,
);
final builder = ConstructorArbolAuto();
final desactivar = items.firstWhere(
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
);
final jazz = items.firstWhere(
(i) => i.id == builder.idPresetEq(PresetEcualizador.jazz.nombre),
);
final marcados = items.where((i) => i.title.contains('')).toList();
expect(desactivar.title, isNot(contains('')));
expect(jazz.title, contains(''));
expect(marcados, hasLength(1));
expect(marcados.single.id, jazz.id);
});
test('con el ecualizador DESACTIVADO, Desactivar queda marcado y NINGÚN '
'preset lo está', () {
final items = itemsEcualizadorAuto(
activo: false,
presetActual: PresetEcualizador.rock,
l10n: l10n,
);
final desactivar = items.firstWhere(
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
);
final marcados = items.where((i) => i.title.contains('')).toList();
expect(desactivar.title, contains(''));
expect(marcados, hasLength(1));
expect(marcados.single.id, ConstructorArbolAuto.idDesactivarEq);
});
test('el label de Desactivar usa l10n.autoEqDisableOption', () {
final items = itemsEcualizadorAuto(
activo: false,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final desactivar = items.firstWhere(
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
);
expect(desactivar.title, contains(l10n.autoEqDisableOption));
});
});
}