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
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z M2,20L4,22L22,4L20,2Z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z" />
</vector>
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "إصلاح",
"alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.",
"alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك."
"alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.",
"autoEqDisableOption": "تعطيل"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "সমাধান করুন",
"alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।",
"alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।"
"alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।",
"autoEqDisableOption": "বন্ধ করুন"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Beheben",
"alarmDiagnosticsIntentUnavailable": "Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.",
"alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen."
"alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen.",
"autoEqDisableOption": "Deaktivieren"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Fix this",
"alarmDiagnosticsIntentUnavailable": "Couldn't open that settings screen on this phone. Try looking for it manually in Settings.",
"alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet."
"alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet.",
"autoEqDisableOption": "Disable"
}
+2 -1
View File
@@ -833,5 +833,6 @@
},
"alarmDiagnosticsFixAction": "Solucionar",
"alarmDiagnosticsIntentUnavailable": "No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.",
"alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma."
"alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma.",
"autoEqDisableOption": "Desactivar"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Corriger",
"alarmDiagnosticsIntentUnavailable": "Impossible d'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.",
"alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme."
"alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme.",
"autoEqDisableOption": "Désactiver"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "ठीक करें",
"alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।",
"alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।"
"alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।",
"autoEqDisableOption": "बंद करें"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Perbaiki",
"alarmDiagnosticsIntentUnavailable": "Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.",
"alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu."
"alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu.",
"autoEqDisableOption": "Nonaktifkan"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Risolvi",
"alarmDiagnosticsIntentUnavailable": "Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.",
"alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia."
"alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia.",
"autoEqDisableOption": "Disattiva"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "修正する",
"alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。",
"alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。"
"alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。",
"autoEqDisableOption": "無効化"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Resolver",
"alarmDiagnosticsIntentUnavailable": "Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.",
"alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme."
"alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme.",
"autoEqDisableOption": "Desativar"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Исправить",
"alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.",
"alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника."
"alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.",
"autoEqDisableOption": "Отключить"
}
+2 -1
View File
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "解决",
"alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。",
"alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。"
"alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。",
"autoEqDisableOption": "关闭"
}
+6
View File
@@ -3187,6 +3187,12 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Todavía no pudimos revisar tus ajustes de alarma.'**
String get alarmDiagnosticsUnavailableHint;
/// No description provided for @autoEqDisableOption.
///
/// In es, this message translates to:
/// **'Desactivar'**
String get autoEqDisableOption;
}
class _AppLocalizationsDelegate
+3
View File
@@ -1760,4 +1760,7 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.';
@override
String get autoEqDisableOption => 'تعطيل';
}
+3
View File
@@ -1768,4 +1768,7 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।';
@override
String get autoEqDisableOption => 'বন্ধ করুন';
}
+3
View File
@@ -1782,4 +1782,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Wir konnten deine Alarmeinstellungen noch nicht prüfen.';
@override
String get autoEqDisableOption => 'Deaktivieren';
}
+3
View File
@@ -1762,4 +1762,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'We couldn\'t check your alarm settings yet.';
@override
String get autoEqDisableOption => 'Disable';
}
+3
View File
@@ -1778,4 +1778,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Todavía no pudimos revisar tus ajustes de alarma.';
@override
String get autoEqDisableOption => 'Desactivar';
}
+3
View File
@@ -1788,4 +1788,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Nous n\'avons pas encore pu vérifier vos paramètres d\'alarme.';
@override
String get autoEqDisableOption => 'Désactiver';
}
+3
View File
@@ -1763,4 +1763,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।';
@override
String get autoEqDisableOption => 'बंद करें';
}
+3
View File
@@ -1772,4 +1772,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Kami belum bisa memeriksa pengaturan alarmmu.';
@override
String get autoEqDisableOption => 'Nonaktifkan';
}
+3
View File
@@ -1786,4 +1786,7 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Non abbiamo ancora potuto controllare le impostazioni della sveglia.';
@override
String get autoEqDisableOption => 'Disattiva';
}
+3
View File
@@ -1711,4 +1711,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint => 'アラームの設定をまだ確認できていません。';
@override
String get autoEqDisableOption => '無効化';
}
+3
View File
@@ -1773,4 +1773,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Ainda não conseguimos verificar as configurações do seu alarme.';
@override
String get autoEqDisableOption => 'Desativar';
}
+3
View File
@@ -1778,4 +1778,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Мы пока не смогли проверить настройки вашего будильника.';
@override
String get autoEqDisableOption => 'Отключить';
}
+3
View File
@@ -1699,4 +1699,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint => '我们还无法检查你的闹钟设置。';
@override
String get autoEqDisableOption => '关闭';
}
+179 -59
View File
@@ -10,6 +10,7 @@ import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -19,16 +20,22 @@ import 'servicio_favoritos.dart';
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
/// list's range returns `[]`, never throws.
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
items.skip(pagina * tamano).take(tamano).toList();
List<T> paginaDe<T>(
List<T> items, {
required int pagina,
required int tamano,
}) => items.skip(pagina * tamano).take(tamano).toList();
/// Whether a page after [pagina] exists for a list of [total] elements
/// (Design ADR-6): `true` iff at least one element remains beyond the
/// current page's slice. The exact-boundary case
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
/// reveal.
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * tamano;
bool hayPaginaSiguiente(
int total, {
required int pagina,
required int tamano,
}) => total > (pagina + 1) * tamano;
/// Browse-tree ordering comparator for a local-music folder's children
/// (Design "Directories before files", item 1): directories sort before
@@ -83,8 +90,7 @@ bool faviconUsable(String? favicon) {
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
// `true`) — check `host.isNotEmpty` explicitly to actually require a
// non-empty authority host.
return (uri.scheme == 'http' || uri.scheme == 'https') &&
uri.host.isNotEmpty;
return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
}
/// Deterministic rotation index over the 4 on-brand fallback arts, same
@@ -98,10 +104,11 @@ int indiceArtePara(String seed) =>
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
/// on-brand art the phone UI would pick for this station (per-station
/// parity), never a launcher-icon lookalike.
String artUriPara(Emisora e) => faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
String artUriPara(Emisora e) =>
faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
/// Formats a human-readable audio-quality hint for the browse row's
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
@@ -212,6 +219,17 @@ class ConstructorArbolAuto {
/// (Design "Local root hidden until a folder is configured").
static const idMusicaLocal = 'musica_local';
/// Root folder id for the "Ecualizador" browsable folder (decision
/// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
/// presets, the currently-active one marked. Deliberately NOT added to
/// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
/// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
/// (which needs `AppLocalizations` -- this pure builder class does not
/// depend on it), not the generic station-list [hijos] path. Unlike
/// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
/// hidden.
static const idEcualizador = 'ecualizador';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -295,12 +313,23 @@ class ConstructorArbolAuto {
};
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local), all non-playable.
/// optionally Música Local, Ecualizador), all non-playable.
///
/// There is deliberately no equalizer folder: EQ is configured on the phone
/// only. The car still gets the right sound, because the per-device preset
/// is applied automatically when the output device changes — that lives in
/// `EstadoEcualizador`, not in this tree.
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// folder" rule that used to live in this doc comment (commit `2403da3`,
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
/// line 40). That rule was sound when written, but predated on-device
/// feedback showing that Android Auto custom actions don't surface
/// enough state for choosing among six presets: a monochrome icon cannot
/// legibly encode "which preset", and many head units render a custom
/// action icon-first, hiding its label. `Ecualizador` is a real
/// browsable folder again: "Desactivar" first, then the six factory
/// presets, the active one marked (children built by
/// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays
/// free of any `AppLocalizations` dependency, unlike that builder).
/// Always present, and LAST in the list (after Música Local, when
/// included) -- unlike [idMusicaLocal] it is never conditionally hidden.
/// Do not "restore" the no-folder rule without re-reading that decision.
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
@@ -311,6 +340,7 @@ class ConstructorArbolAuto {
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
_carpeta(idEcualizador, 'Ecualizador'),
];
MediaItem _carpeta(String id, String titulo) => MediaItem(
@@ -457,10 +487,11 @@ class ConstructorArbolAuto {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -486,8 +517,7 @@ class ConstructorArbolAuto {
// (direct or nested), absent for a folder that is genuinely empty
// even recursively (Spec "Folder has no tracks").
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
if (hayContenidoReproducible)
_itemReproducirAleatorio(documentIdPadre),
if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
@@ -725,10 +755,11 @@ class ConstructorArbolAuto {
final ordenados = [...buckets[idxBucket].nodos]
..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -743,20 +774,21 @@ class ConstructorArbolAuto {
}
final meta = metadatos[nodo.documentId];
final tituloMeta = meta?.titulo?.trim();
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final titulo =
(tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artUri =
(artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artistaMeta = meta?.artista?.trim();
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: titulo,
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
? artistaMeta
: null,
artist:
(artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
playable: true,
artUri: Uri.parse(artUri),
displaySubtitle: subtituloCalidadLocal(meta),
@@ -777,15 +809,17 @@ class ConstructorArbolAuto {
required List<GrupoFavoritos> grupos,
required List<Emisora> favoritos,
}) {
final carpetas = grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar = favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
final carpetas =
grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar =
favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
}
@@ -801,13 +835,62 @@ class ConstructorArbolAuto {
if (!esCarpetaGrupo(grupoMediaId)) return const [];
final id = grupoMediaId.substring(_prefijoGrupo.length);
if (id.isEmpty) return const [];
final miembros = favoritos
.where((e) => e.grupoFavoritosId == id)
.toList();
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Equalizer preset-selection media-id prefix (decision
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` for the six
/// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
/// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
/// other prefix/bare id in this class -- diverges from every sibling
/// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
/// bare folder id (none of which starts with "eq_preset:").
static const _prefijoPresetEq = 'eq_preset:';
/// Reserved sentinel raw value for the "Desactivar" item under
/// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
/// collides with a real [PresetEcualizador.nombre]; none of the six
/// factory presets is named this.
static const _valorDesactivarEq = '_off_';
/// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
/// sentinel under [_prefijoPresetEq].
static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
/// Whether [id] identifies an item under the Ecualizador folder (a
/// factory preset OR "Desactivar").
bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
/// Whether [id] is specifically the "Desactivar" item (not a factory
/// preset). Only meaningful alongside [esPresetEqMediaId].
bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
/// Builds a factory preset's selection media id, matched by raw
/// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
/// [PresetEcualizador.presets] already uses for equality, so a locale
/// change never breaks resolution.
String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
/// Resolves an `eq_preset:<nombre>` [id] to the matching factory
/// [PresetEcualizador] from [presets] (defaults to
/// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
/// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
/// any id that doesn't match [esPresetEqMediaId] -- never throws.
PresetEcualizador? resolverPresetEq(
String id, {
List<PresetEcualizador>? presets,
}) {
if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
final nombre = id.substring(_prefijoPresetEq.length);
final lista = presets ?? PresetEcualizador.presets;
for (final preset in lista) {
if (preset.nombre == nombre) return preset;
}
return null;
}
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
@@ -847,6 +930,43 @@ Future<void> reproducirPorMediaId(
await reproducir(item);
}
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
/// resolved factory preset to [aplicarPreset] -- turning the equalizer
/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
/// is currently `false`, so tapping a preset while the equalizer is off
/// both re-enables it AND applies the tapped preset's gains (Spec
/// "selecting a preset while disabled enables it and applies it"), never
/// silently just remembering the preset for later. [aplicarPreset] runs
/// BEFORE the enable check so the native engine only ever pushes gains
/// once, for the NEW preset -- never once for whatever was active before,
/// then again for the new one.
///
/// A stale/unresolvable id, or any id that doesn't match
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
/// runs and no exception propagates.
Future<void> seleccionarPresetEqPorMediaId(
String id, {
required bool activo,
required Future<void> Function(PresetEcualizador) aplicarPreset,
required Future<void> Function(bool) activarEcualizador,
}) async {
final constructor = ConstructorArbolAuto();
if (!constructor.esPresetEqMediaId(id)) return;
if (constructor.esDesactivarEqMediaId(id)) {
await activarEcualizador(false);
return;
}
final preset = constructor.resolverPresetEq(id);
if (preset == null) return;
await aplicarPreset(preset);
if (!activo) await activarEcualizador(true);
}
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
@@ -904,10 +1024,8 @@ List<NodoLocal> ordenarPorCalidadLocal(
) {
final ordenados = List<NodoLocal>.from(nodos);
ordenados.sort(
(a, b) => compararCalidadLocal(
metadatos[a.documentId],
metadatos[b.documentId],
),
(a, b) =>
compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
);
return ordenados;
}
@@ -948,12 +1066,13 @@ List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
return _rangosBucket.map((rango) {
final (etiqueta, desde, hasta) = rango;
final coincidencias = pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
final coincidencias =
pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
}).toList();
}
@@ -1119,9 +1238,10 @@ Future<void> reproducirCarpetaLocal(
}
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
final pistas = aleatorio
? mezclarFisherYates(recolectadas, rng ?? Random())
: recolectadas;
final pistas =
aleatorio
? mezclarFisherYates(recolectadas, rng ?? Random())
: recolectadas;
if (pistas.isEmpty) return;
await iniciarCola(pistas);
+143 -32
View File
@@ -66,7 +66,10 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
/// the browse tree and the car-tap path already show, instead of a blank
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
/// dependency — so it is unit-testable without instantiating the handler.
MediaItem mediaItemParaEmisora(Emisora emisora, {required AppLocalizations l10n}) {
MediaItem mediaItemParaEmisora(
Emisora emisora, {
required AppLocalizations l10n,
}) {
return MediaItem(
id: emisora.url,
title: localizedStationName(l10n, emisora.nombre),
@@ -108,7 +111,6 @@ Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
/// `MediaControl`/`customAction` namespace, never compared against a
/// media id).
const accionEqToggle = 'eq_toggle';
const accionEqPresetSiguiente = 'eq_preset_siguiente';
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
@@ -151,38 +153,126 @@ String nombrePresetVisible(AppLocalizations l10n, String nombre) {
}
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
/// screen (Design "EQ custom actions", item 4) — deliberately just 2: an
/// on/off toggle plus a cycling-preset action, NOT one action per preset,
/// since Android Auto only surfaces a limited number of custom actions.
/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
/// toggle. The previous design paired this with a SECOND action that cycled
/// through the six factory presets; that action is REMOVED. On-device
/// feedback: many head units render custom actions icon-first, so two
/// static, non-parametrized glyphs sitting side by side looked identical/
/// dead even though the toggle's own icon DID change and the cycle action
/// DID work — a monochrome icon simply cannot legibly encode "which of six
/// presets" the way a browsable list's text rows can. Preset selection now
/// lives in the "Ecualizador" browsable folder instead (see
/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
/// instead.
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
/// without the native Equalizer effect gets no EQ actions at all, not
/// broken ones. Reuses the SAME bundled `ic_stat_pluriwave` drawable the
/// notification's own status-bar icon already uses (an equalizer-bars
/// glyph) — zero new native assets. Pure, no handler dependency.
/// broken ones.
///
/// On-device feedback follow-up: this action used to reuse the SAME
/// `ic_stat_pluriwave` drawable as everything else and was visually
/// indistinguishable on a car head unit, which foregrounds the icon over
/// the label. It now gets its own dedicated drawables
/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
/// handler dependency.
List<MediaControl> controlesEcualizadorPersonalizados({
required bool disponible,
required bool activo,
required PresetEcualizador presetActual,
required AppLocalizations l10n,
}) {
if (!disponible) return const [];
return [
MediaControl.custom(
androidIcon: 'drawable/ic_stat_pluriwave',
label: activo
? l10n.eqCustomActionDisableLabel
: l10n.eqCustomActionEnableLabel,
androidIcon:
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
label:
activo
? l10n.eqCustomActionDisableLabel
: l10n.eqCustomActionEnableLabel,
name: accionEqToggle,
),
MediaControl.custom(
androidIcon: 'drawable/ic_stat_pluriwave',
label: l10n.eqCustomActionPresetLabel(
nombrePresetVisible(l10n, presetActual.nombre),
),
name: accionEqPresetSiguiente,
];
}
/// Content-style extras for the Ecualizador folder's items (decision
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
/// are deliberately duplicated across this handler/service layer and the
/// pure browse-tree builder layer rather than cross-layer-shared). List
/// style, not grid: these items are short text options with no artwork of
/// their own, unlike a station or local-track tile.
const _contentStyleListaEq = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
};
/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
/// preset must be visibly marked").
///
/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
/// and REJECTED as the marking mechanism: this project's `audio_service`
/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
/// 's list/grid hints are exposed — and the raw platform key itself is
/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
/// was already listened to"), not item SELECTION; repurposing it here could
/// render as "already played" on some head units, which would be actively
/// misleading for a preset picker, and there is no way to verify its actual
/// rendering on a real head unit from this environment. A plain-text
/// marker renders identically and unambiguously on every head unit, which
/// an unverifiable, semantically-mismatched extras key cannot guarantee.
String _marcarActivoEq(String titulo, {required bool activo}) =>
activo ? '$titulo' : titulo;
/// Builds the "Ecualizador" folder's children for the Android Auto browse
/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
/// six factory presets in [PresetEcualizador.presets] order, each localized
/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
/// label already uses, so a preset's name reads identically whether the
/// driver sees it in the now-playing screen's tooltip or in this folder.
/// All items are playable: tapping one is dispatched through
/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
/// dart`), the same seam every other browse-tree leaf already uses; this
/// folder has no sub-browsing. Exactly one item is marked active via
/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
/// whichever preset equals [presetActual] — never both at once, and never
/// zero once this function is reached (an unresolvable [presetActual] with
/// [activo] `true` simply marks nothing, which cannot happen in practice
/// since [presetActual] always originates from [PresetEcualizador.presets]
/// or a "Personalizado" tweak that would just leave every item unmarked
/// rather than mis-marking one).
List<MediaItem> itemsEcualizadorAuto({
required bool activo,
required PresetEcualizador presetActual,
required AppLocalizations l10n,
}) {
final constructor = ConstructorArbolAuto();
final items = <MediaItem>[
MediaItem(
id: ConstructorArbolAuto.idDesactivarEq,
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
playable: true,
extras: _contentStyleListaEq,
),
];
for (final preset in PresetEcualizador.presets) {
items.add(
MediaItem(
id: constructor.idPresetEq(preset.nombre),
title: _marcarActivoEq(
nombrePresetVisible(l10n, preset.nombre),
activo: activo && preset == presetActual,
),
playable: true,
extras: _contentStyleListaEq,
),
);
}
return items;
}
/// Wrapper de alto nivel para el UI.
@@ -473,7 +563,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
presetActual: _presetActual,
l10n: _textos,
);
@@ -1081,14 +1170,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _reproducirEntradaCola(anterior.actual);
}
/// Dispatches the equalizer's 2 custom actions (item 4, Design "EQ custom
/// actions"): `accionEqToggle` flips on/off, `accionEqPresetSiguiente`
/// cycles to the next factory preset. Both delegate to the existing
/// [setEcualizadorActivo]/[aplicarPreset] — the SAME entry points the
/// Dispatches the equalizer's only custom action (decision
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
/// to the existing [setEcualizadorActivo] — the SAME entry point the
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
/// phone tap have identical effects and both refresh the custom action's
/// label via `_actualizarControlesEq()` (already wired into those two
/// methods). Any other [name] is a no-op — never throws.
/// phone tap have identical effects and both refresh the action's label
/// via `_actualizarControlesEq()` (already wired into that method). The
/// preset-cycling action that used to live here is REMOVED — preset
/// selection now goes through the "Ecualizador" browsable folder (see
/// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched
/// from [playFromMediaId] below). Any other [name] is a no-op — never
/// throws.
@override
Future<dynamic> customAction(
String name, [
@@ -1097,8 +1189,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
switch (name) {
case accionEqToggle:
await setEcualizadorActivo(!_ecualizadorActivo);
case accionEqPresetSiguiente:
await aplicarPreset(presetSiguiente(_presetActual));
}
}
@@ -1145,6 +1235,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler
fuente: fuenteLocal,
);
if (musicaLocal != null) return musicaLocal;
// Ecualizador folder (decision `auto/ecualizador-diseno`): needs no
// external data source, unlike every branch below it -- checked
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
// local-music branch above is also resolved before that gate.
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return itemsEcualizadorAuto(
activo: _ecualizadorActivo,
presetActual: _presetActual,
l10n: _textos,
);
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return const [];
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
@@ -1215,14 +1316,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (fuenteLocal == null) return;
await reproducirCarpetaLocal(
mediaId,
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(
mediaId,
),
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId),
fuente: fuenteLocal,
iniciarCola: _iniciarColaLocal,
);
return;
}
// Equalizer preset selection (decision `auto/ecualizador-diseno`):
// THIRD branch, same unconditional-return shape as the two above --
// an `eq_preset:` id never falls through to station routing.
if (constructorArbol.esPresetEqMediaId(mediaId)) {
await seleccionarPresetEqPorMediaId(
mediaId,
activo: _ecualizadorActivo,
aplicarPreset: aplicarPreset,
activarEcualizador: setEcualizadorActivo,
);
return;
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return;
await reproducirPorMediaId(
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));
});
});
}