From eea8ec31e6e1121f9d29ccf22260377d2efe77be Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:38:39 +0200 Subject: [PATCH 1/6] fix(auto): sort local-music subfolders before files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit itemsLocales sorted a folder's children by name only, mixing directories and files. A subfolder whose name sorted after enough tracks (e.g. "Live" behind 80 numbered tracks) landed on a later "Más..." page, making it unreachable without paging through every track first. Sort directories before files, then by name within each group -- the standard file-browser convention. Subfolders now always land on page 0. --- lib/servicios/navegacion_auto.dart | 17 ++- test/servicios/navegacion_auto_test.dart | 171 +++++++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 0509862..8c0a3ae 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -30,6 +30,21 @@ List paginaDe(List items, {required int pagina, required int 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 +/// files regardless of name, and within each group, alphabetically by +/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a +/// driver-facing bug where a folder's subfolders could land on a later +/// "Más…" page whenever enough tracks sorted alphabetically ahead of them +/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the +/// subfolder unreachable without paging through every track first. +int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) { + if (a.esDirectorio != b.esDirectorio) { + return a.esDirectorio ? -1 : 1; + } + return a.nombre.compareTo(b.nombre); +} + const _prefijoEmisora = 'emisora:'; /// Local-track media-id prefix (Design "media-id scheme"), collision-free @@ -434,7 +449,7 @@ class ConstructorArbolAuto { MediaItem Function(NodoLocal, Map)? construirItem, }) async { final construir = construirItem ?? _itemLocal; - final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre)); + final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion); final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano); final docIds = paginaActual .where((n) => !n.esDirectorio) diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index c1b504a..666698b 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2829,6 +2829,177 @@ void main() { }, ); }); + + // ── Item 1: Android Auto subfolder visibility ─────────────────────────── + // `itemsLocales` used to sort a folder's children by name ONLY, mixing + // directories and files -- a subfolder whose name sorted alphabetically + // after enough tracks landed on a later "Más…" page, making it invisible + // until the driver paged through every track first. Fix: directories + // before files, then by name within each group (standard file-browser + // convention) -- subfolders always land on the first page. + group( + 'compararNodoLocalParaNavegacion (Design "Directories before files")', + () { + test('a directory always sorts before a file, regardless of name', () { + const archivo = NodoLocal( + documentId: 'f1', + nombre: 'AAA archivo.mp3', + esDirectorio: false, + ); + const carpeta = NodoLocal( + documentId: 'd1', + nombre: 'ZZZ carpeta', + esDirectorio: true, + ); + + expect( + compararNodoLocalParaNavegacion(carpeta, archivo), + lessThan(0), + ); + expect( + compararNodoLocalParaNavegacion(archivo, carpeta), + greaterThan(0), + ); + }); + + test('two directories sort alphabetically by name', () { + const a = NodoLocal( + documentId: 'd1', + nombre: 'Alpha', + esDirectorio: true, + ); + const b = NodoLocal( + documentId: 'd2', + nombre: 'Beta', + esDirectorio: true, + ); + + expect(compararNodoLocalParaNavegacion(a, b), lessThan(0)); + expect(compararNodoLocalParaNavegacion(b, a), greaterThan(0)); + }); + + test('two files sort alphabetically by name', () { + const a = NodoLocal( + documentId: 'f1', + nombre: 'Alpha.mp3', + esDirectorio: false, + ); + const b = NodoLocal( + documentId: 'f2', + nombre: 'Beta.mp3', + esDirectorio: false, + ); + + expect(compararNodoLocalParaNavegacion(a, b), lessThan(0)); + }); + + test('equal esDirectorio and equal name compares equal', () { + const a = NodoLocal( + documentId: 'f1', + nombre: 'Same.mp3', + esDirectorio: false, + ); + const b = NodoLocal( + documentId: 'f2', + nombre: 'Same.mp3', + esDirectorio: false, + ); + + expect(compararNodoLocalParaNavegacion(a, b), 0); + }); + }, + ); + + group( + 'ConstructorArbolAuto.itemsLocales: subfolders sort before files ' + '(Android Auto subfolder-visibility bug)', + () { + test( + 'a subfolder whose name sorts AFTER every one of 80 numbered ' + 'tracks (e.g. "Live") still lands on page 0 -- pure alphabetical ' + 'mixing used to push it to a later page, making it unreachable ' + 'without paging through every track first (the reported bug)', + () async { + final nodos = [ + for (var i = 1; i <= 80; i++) + NodoLocal( + documentId: 'track-$i', + nombre: '${i.toString().padLeft(3, '0')} Track.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'folder-live', + nombre: 'Live', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'root', + metadatosDe: _metadatosVacio, + ); + + expect( + pagina0.where((i) => i.id == 'carpeta_local:folder-live'), + hasLength(1), + reason: + 'the "Live" subfolder must be visible on the FIRST page, ' + 'not hidden behind 80 tracks on a later page', + ); + }, + ); + + test( + 'mixed directories and files: ALL directories precede ALL files, ' + 'each group still alphabetical by name', + () async { + final nodos = [ + const NodoLocal( + documentId: 'f-z', + nombre: 'zzz.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-z', + nombre: 'ZZZ Carpeta', + esDirectorio: true, + ), + const NodoLocal( + documentId: 'f-a', + nombre: 'aaa.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-a', + nombre: 'AAA Carpeta', + esDirectorio: true, + ), + ]; + + final items = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'root', + metadatosDe: _metadatosVacio, + ); + final soloArbol = items + .where( + (i) => + i.id.startsWith('carpeta_local:') || + i.id.startsWith('pista:'), + ) + .toList(); + + expect(soloArbol.map((i) => i.id).toList(), [ + 'carpeta_local:d-a', + 'carpeta_local:d-z', + 'pista:f-a', + 'pista:f-z', + ]); + }, + ); + }, + ); } /// Fake `metadatosDe` that always resolves to an empty map — used by every From 6822432a51cf5ae3648c9e1dc379cdb0ef99a488 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:43:20 +0200 Subject: [PATCH 2/6] feat(auto): play a local-music folder's subfolders recursively too totalPistas counted only DIRECT audio children, so "Reproducir carpeta"/ "Aleatorio" were hidden for a folder that contains only subfolders, and playing a folder queued only its direct tracks. Add a bounded recursive walk (pistasRecursivas) that collects every track beneath a folder, depth-first, sorted by name at each level. Bounded on two independent axes to keep a single tap's native SAF round-trips and in-memory list size predictable on a deep or wide library: - depth: 4 levels below the tapped folder (profundidadMaximaRecursivaLocal) - count: 500 tracks total (limitePistasRecursivasLocal) The folder-play/shuffle actions are now offered whenever the recursive count is > 0, and "Reproducir carpeta"/"Aleatorio" queue everything found, not just direct children. --- lib/servicios/navegacion_auto.dart | 185 ++++++++-- test/servicios/navegacion_auto_test.dart | 411 ++++++++++++++++++++++- 2 files changed, 569 insertions(+), 27 deletions(-) diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 8c0a3ae..84a78ef 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -447,6 +447,12 @@ class ConstructorArbolAuto { int tamano = _maxItemsCarpetaLocal, @visibleForTesting MediaItem Function(NodoLocal, Map)? construirItem, + // Item 2 (recursive folder play): optional so every pre-existing call + // site/test that has no need for the recursive gate keeps working + // unchanged. Only used on page 0, and only when [nodos] has zero + // DIRECT tracks (a direct track already makes the gate cheaply true + // without it) — see the `hayContenidoReproducible` computation below. + FuenteMusicaLocalAuto? fuente, }) async { final construir = construirItem ?? _itemLocal; final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion); @@ -462,14 +468,26 @@ class ConstructorArbolAuto { } if (pagina == 0) { final totalPistas = nodos.where((n) => !n.esDirectorio).length; + // Item 2: a folder plays everything beneath it, recursively -- so + // the play actions must be offered whenever the RECURSIVE count is + // > 0, not just the direct count. `totalPistas > 0` short-circuits + // the bounded recursive walk entirely for the common case (a direct + // track already answers the question); only a folder with ZERO + // direct tracks but at least one subfolder pays the recursive-check + // cost, and only up to [profundidadMaximaRecursivaLocal] levels. + final hayContenidoReproducible = + totalPistas > 0 || + (fuente != null && + await _haySubcarpetaConPistas(nodos, fuente: fuente)); final prepend = [ - // Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE - // the sort/bucket nav entries, guarded the same shape as - // ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has - // at least one direct audio child, absent for a folder with only - // subfolders (Spec "Folder has no tracks"). - if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre), - if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre), + // Folder-play actions (Design ADR-5, Phase 3; recursive gate item + // 2): prepended BEFORE the sort/bucket nav entries, present iff + // the folder has at least one playable track anywhere beneath it + // (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 (ofreceOrdenCalidad(totalPistas)) _itemModoOrdenCalidad(documentIdPadre), if (ofreceBuckets(totalPistas)) @@ -481,6 +499,32 @@ class ConstructorArbolAuto { return items; } + /// Whether at least one subfolder within [nodos] recursively contains a + /// playable track (Design "recursive folder play, gate", item 2): called + /// ONLY when the folder has zero DIRECT tracks (the caller already + /// checked that cheaply) — descends into each direct subfolder via + /// [pistasRecursivas] with `limite: 1`, stopping at the very first + /// match so a folder with an early hit costs as little as possible. + /// [nodos] is assumed already resolved by the caller (its own + /// `fuente.hijos(...)` result), so this folder's own children are never + /// re-fetched. + Future _haySubcarpetaConPistas( + List nodos, { + required FuenteMusicaLocalAuto fuente, + }) async { + for (final nodo in nodos) { + if (!nodo.esDirectorio) continue; + final encontradas = await pistasRecursivas( + nodo.documentId, + fuente: fuente, + profundidadMaxima: profundidadMaximaRecursivaLocal - 1, + limite: 1, + ); + if (encontradas.isNotEmpty) return true; + } + return false; + } + /// Whether the "Ordenar por calidad" mode entry should be offered for a /// folder with [totalPistas] audio files (Design ADR-3): present for /// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the @@ -946,17 +990,115 @@ List mezclarFisherYates(List nodos, Random rng) { List pistasEnOrdenAleatorio(List nodos, Random rng) => mezclarFisherYates(pistasEnOrdenNombre(nodos), rng); +/// Maximum recursion depth for "play folder recursively" (Design "recursive +/// folder play, cost bound", item 2): SAF directory listing is a native +/// round-trip PER folder, so unbounded recursion could turn a single tap +/// into dozens of channel calls for a pathologically deep tree. 4 levels +/// below the tapped folder covers virtually every real music-library +/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while +/// keeping a worst-case tree's native-call count bounded. A subfolder +/// beyond this depth is simply never explored — its tracks are not +/// collected, exactly like content beyond the browse tree's own page cap +/// is never listed. +const profundidadMaximaRecursivaLocal = 4; + +/// Maximum number of tracks collected by a recursive folder walk (Design +/// "recursive folder play, cost bound", item 2): a folder-play/shuffle +/// queue beyond a few hundred tracks has no practical benefit, and an +/// unbounded collection risks an extremely long queue AND an extremely +/// long recursive walk over a huge library. 500 is an order of magnitude +/// above the existing quality-sort cap +/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for +/// a "play everything" action, while still bounded. +const limitePistasRecursivasLocal = 500; + +/// Recursively collects every audio-file [NodoLocal] reachable from +/// [documentId] (Design "recursive folder play", item 2): [documentId]'s +/// own direct audio children, plus — for every direct subfolder — that +/// subfolder's own recursive result. Walked depth-first, sorted by +/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/ +/// shuffle play actions already used pre-recursion), so the collected +/// order is deterministic and reproducible under a fixed shuffle seed. +/// +/// Bounded on two independent axes so a pathological tree (very deep, or +/// very wide-and-deep) can never turn a single tap into an unbounded +/// number of native SAF round-trips or an unbounded in-memory list: +/// - [profundidadMaxima] caps how many folder levels BELOW [documentId] +/// are ever descended into (`0` = only [documentId]'s own direct +/// children, no descent at all). +/// - [limite] caps the TOTAL number of tracks collected across the whole +/// walk; collection stops (mid-folder if needed) the instant this many +/// have been gathered. +/// +/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked +/// permission, a race with the OS SAF layer) is swallowed for that +/// subfolder only — sibling folders already queued for traversal are +/// still visited — mirroring this file's existing no-throw contract +/// (Design "no-op on empty/unresolvable folder"). +Future> pistasRecursivas( + String documentId, { + required FuenteMusicaLocalAuto fuente, + int profundidadMaxima = profundidadMaximaRecursivaLocal, + int limite = limitePistasRecursivasLocal, +}) async { + final resultado = []; + await _recolectarPistasRecursivas( + documentId, + fuente: fuente, + profundidadRestante: profundidadMaxima, + limite: limite, + resultado: resultado, + ); + return resultado; +} + +Future _recolectarPistasRecursivas( + String documentId, { + required FuenteMusicaLocalAuto fuente, + required int profundidadRestante, + required int limite, + required List resultado, +}) async { + if (resultado.length >= limite) return; + final List hijos; + try { + hijos = await fuente.hijos(documentId); + } catch (_) { + return; + } + final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre)); + for (final nodo in ordenados) { + if (resultado.length >= limite) return; + if (nodo.esDirectorio) { + if (profundidadRestante <= 0) continue; + await _recolectarPistasRecursivas( + nodo.documentId, + fuente: fuente, + profundidadRestante: profundidadRestante - 1, + limite: limite, + resultado: resultado, + ); + } else { + resultado.add(nodo); + } + } +} + /// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design -/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the -/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP — -/// the prefix itself is authoritative), fetches [fuente]'s direct children -/// for that folder, filters to audio files, orders them ([aleatorio] picks -/// shuffled vs name order), and hands the resulting list to [iniciarCola]. +/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item +/// 2): resolves whichever of the two action prefixes matches [id] +/// (ignoring [aleatorio] for the STRIP — the prefix itself is +/// authoritative), RECURSIVELY collects every track beneath that folder +/// via [pistasRecursivas] (direct children AND every nested subfolder, up +/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs +/// the recursive walk's own name-sorted order), and hands the resulting +/// list to [iniciarCola]. /// /// A no-op (never calls [iniciarCola]) when: [id] matches neither action -/// prefix; [fuente.hijos] throws or returns only directories (an -/// unresolvable/empty folder — Design "no-op on empty/unresolvable -/// folder"). +/// prefix; the folder (or everything beneath it, within the recursion +/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable +/// folder") — [pistasRecursivas] never throws, so this never propagates an +/// exception either. Future reproducirCarpetaLocal( String id, { required bool aleatorio, @@ -974,16 +1116,10 @@ Future reproducirCarpetaLocal( return; } - final List nodos; - try { - nodos = await fuente.hijos(documentId); - } catch (_) { - return; - } - + final recolectadas = await pistasRecursivas(documentId, fuente: fuente); final pistas = aleatorio - ? pistasEnOrdenAleatorio(nodos, rng ?? Random()) - : pistasEnOrdenNombre(nodos); + ? mezclarFisherYates(recolectadas, rng ?? Random()) + : recolectadas; if (pistas.isEmpty) return; await iniciarCola(pistas); @@ -1129,6 +1265,7 @@ Future?> hijosMusicaLocal( documentIdPadre: documentId, pagina: pagina, metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), + fuente: fuente, ); } catch (_) { return const []; diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 666698b..f7f6abc 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2334,8 +2334,9 @@ void main() { ); test( - 'carpeta con solo subcarpetas (sin pistas de audio directas) es un ' - 'no-op: iniciarCola nunca se llama', + 'carpeta con solo subcarpetas (sin pistas de audio directas), y esa ' + 'subcarpeta TAMBIÉN está vacía incluso recursivamente, es un no-op: ' + 'iniciarCola nunca se llama', () async { final fuente = _FakeFuenteMusicaLocalAuto( hijosPorDocId: { @@ -2361,6 +2362,43 @@ void main() { }, ); + test( + 'item 2: carpeta con SOLO una subcarpeta que sí tiene pistas -- las ' + 'recolecta RECURSIVAMENTE y llama iniciarCola con ellas (antes era ' + 'un no-op)', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'carpeta1': const [ + NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ], + 'd-sub': const [ + NodoLocal( + documentId: 'd-honda', + nombre: 'honda.mp3', + esDirectorio: false, + ), + ], + }, + ); + List? recibidas; + + await reproducirCarpetaLocal( + 'carpeta_local_reproducir:carpeta1', + aleatorio: false, + fuente: fuente, + iniciarCola: (pistas) async => recibidas = pistas, + ); + + expect(recibidas, isNotNull); + expect(recibidas!.map((n) => n.documentId).toList(), ['d-honda']); + }, + ); + test( 'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar ' 'la excepción', @@ -3000,6 +3038,363 @@ void main() { ); }, ); + + // ── Item 2: Android Auto recursive folder play ───────────────────────── + // totalPistas used to count only DIRECT audio children, so "Reproducir + // carpeta"/"Aleatorio" were hidden for a folder that contains only + // subfolders, and playing a folder queued only its direct tracks. Fix: a + // bounded recursive walk collects every track beneath a folder, and the + // play actions are offered whenever that recursive count is > 0. + group('pistasRecursivas (Design "recursive folder play", item 2)', () { + test( + 'flat folder (no subfolders): same tracks as the direct-children-' + 'only view, sorted by name', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false), + NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false), + NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false), + ], + }, + ); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect( + resultado.map((n) => n.documentId).toList(), + ['d-a', 'd-b', 'd-c'], + ); + }, + ); + + test( + 'a folder that contains ONLY subfolders: recursively collects the ' + 'tracks from every one of them', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal(documentId: 'sub-a', nombre: 'A', esDirectorio: true), + NodoLocal(documentId: 'sub-b', nombre: 'B', esDirectorio: true), + ], + 'sub-a': const [ + NodoLocal(documentId: 't1', nombre: 'uno.mp3', esDirectorio: false), + ], + 'sub-b': const [ + NodoLocal(documentId: 't2', nombre: 'dos.mp3', esDirectorio: false), + ], + }, + ); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado.map((n) => n.documentId).toSet(), {'t1', 't2'}); + }, + ); + + test( + 'a nested tree deeper than one level: finds a track 3 levels below ' + 'the tapped folder', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal( + documentId: 'nivel1', + nombre: 'Nivel1', + esDirectorio: true, + ), + ], + 'nivel1': const [ + NodoLocal( + documentId: 'nivel2', + nombre: 'Nivel2', + esDirectorio: true, + ), + ], + 'nivel2': const [ + NodoLocal( + documentId: 'nivel3', + nombre: 'Nivel3', + esDirectorio: true, + ), + ], + 'nivel3': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], + }, + ); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado.map((n) => n.documentId).toList(), ['t-hondo']); + }, + ); + + test( + 'depth bound: a track beyond profundidadMaxima levels is NOT ' + 'collected', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal(documentId: 'n1', nombre: 'N1', esDirectorio: true), + ], + 'n1': const [ + NodoLocal(documentId: 'n2', nombre: 'N2', esDirectorio: true), + ], + 'n2': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], + }, + ); + + // profundidadMaxima: 1 allows root -> n1 (1 hop) but NOT n1 -> n2 + // (a 2nd hop), so the track inside n2 is never reached. + final resultado = await pistasRecursivas( + 'root', + fuente: fuente, + profundidadMaxima: 1, + ); + + expect(resultado, isEmpty); + }, + ); + + test( + 'count bound: stops collecting once limite tracks are gathered, ' + 'even if more exist', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': List.generate( + 10, + (i) => NodoLocal( + documentId: 't$i', + nombre: 'cancion_$i.mp3', + esDirectorio: false, + ), + ), + }, + ); + + final resultado = await pistasRecursivas( + 'root', + fuente: fuente, + limite: 3, + ); + + expect(resultado, hasLength(3)); + }, + ); + + test( + 'a subfolder that fails to resolve (revoked permission) is skipped; ' + 'sibling subfolders are still visited, never throws', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'root': const [ + NodoLocal( + documentId: 'sub-mala', + nombre: 'A-Mala', + esDirectorio: true, + ), + NodoLocal( + documentId: 'sub-buena', + nombre: 'B-Buena', + esDirectorio: true, + ), + ], + 'sub-buena': const [ + NodoLocal(documentId: 't-ok', nombre: 'ok.mp3', esDirectorio: false), + ], + }, + idsConErrorEnHijos: const {'sub-mala'}, + ); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado.map((n) => n.documentId).toList(), ['t-ok']); + }, + ); + + test('carpeta vacía (sin hijos en absoluto) devuelve lista vacía', () async { + final fuente = _FakeFuenteMusicaLocalAuto(); + + final resultado = await pistasRecursivas('root', fuente: fuente); + + expect(resultado, isEmpty); + }); + }); + + group( + 'ConstructorArbolAuto.itemsLocales: recursive folder-play gate ' + '(item 2)', + () { + test( + 'folder with ONLY subfolders, but a subfolder recursively ' + 'contains a track (fuente injected): DOES prepend both play ' + 'actions', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'sub1': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], + }, + ); + final nodos = [ + const NodoLocal( + documentId: 'sub1', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + fuente: fuente, + ); + + expect( + pagina0.where( + (i) => i.id.startsWith('carpeta_local_reproducir:'), + ), + hasLength(1), + ); + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), + hasLength(1), + ); + }, + ); + + test( + 'folder with only subfolders, nested 2 levels deep, track only ' + 'at the deepest level (fuente injected): still prepends both ' + 'actions', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + hijosPorDocId: { + 'nivel1': const [ + NodoLocal( + documentId: 'nivel2', + nombre: 'N2', + esDirectorio: true, + ), + ], + 'nivel2': const [ + NodoLocal( + documentId: 't-hondo', + nombre: 'hondo.mp3', + esDirectorio: false, + ), + ], + }, + ); + final nodos = [ + const NodoLocal( + documentId: 'nivel1', + nombre: 'Nivel1', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + fuente: fuente, + ); + + expect( + pagina0.where( + (i) => i.id.startsWith('carpeta_local_reproducir:'), + ), + hasLength(1), + ); + }, + ); + + test( + 'folder with only subfolders that are genuinely empty even ' + 'recursively (fuente injected): still does NOT prepend', + () async { + final fuente = _FakeFuenteMusicaLocalAuto(); + final nodos = [ + const NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + fuente: fuente, + ); + + expect( + pagina0.where( + (i) => i.id.startsWith('carpeta_local_reproducir:'), + ), + isEmpty, + ); + expect( + pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')), + isEmpty, + ); + }, + ); + + test( + 'without a fuente (omitted): falls back to direct-count-only ' + 'gating -- folder with only subfolders never prepends, matching ' + 'the pre-item-2 behaviour', + () async { + final nodos = [ + const NodoLocal( + documentId: 'd-sub', + nombre: 'Subcarpeta', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'padre1', + metadatosDe: _metadatosVacio, + ); + + expect( + pagina0.where( + (i) => i.id.startsWith('carpeta_local_reproducir:'), + ), + isEmpty, + ); + }, + ); + }, + ); } /// Fake `metadatosDe` that always resolves to an empty map — used by every @@ -3075,13 +3470,15 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { Object? errorEnHijos, Object? errorEnUriContenido, Object? errorEnMetadatosDe, + Set? idsConErrorEnHijos, }) : _configurada = configurada, _hijosPorDocId = hijosPorDocId ?? const {}, _uriPorDocId = uriPorDocId ?? const {}, _metadatosPorDocId = metadatosPorDocId ?? const {}, _errorEnHijos = errorEnHijos, _errorEnUriContenido = errorEnUriContenido, - _errorEnMetadatosDe = errorEnMetadatosDe; + _errorEnMetadatosDe = errorEnMetadatosDe, + _idsConErrorEnHijos = idsConErrorEnHijos ?? const {}; final bool _configurada; final Map> _hijosPorDocId; @@ -3091,6 +3488,11 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { final Object? _errorEnUriContenido; final Object? _errorEnMetadatosDe; + /// Item 2 (recursive folder play): documentIds whose `hijos` call throws + /// -- used to prove a single unresolvable subfolder does not abort the + /// whole recursive walk, unlike [_errorEnHijos] (which fails EVERY call). + final Set _idsConErrorEnHijos; + /// Records every `metadatosDe` call's documentIds list, in call order — /// used by tests asserting the page-scoped resolution invariant at the /// `hijosMusicaLocal` level. @@ -3102,6 +3504,9 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { @override Future> hijos(String documentId) async { if (_errorEnHijos != null) throw _errorEnHijos; + if (_idsConErrorEnHijos.contains(documentId)) { + throw Exception('permiso revocado para $documentId'); + } return _hijosPorDocId[documentId] ?? const []; } From 1b0bea54924f44b8952b4c0d94a8cee1841a9ead Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:47:26 +0200 Subject: [PATCH 3/6] fix(auto): fall back to on-brand artwork when a station or track has none Stations and tracks with no artwork showed empty tiles in the car. The browse tree's itemEmisora/_itemLocal already fell back to the rotating station_art_* drawable via artUriPara/artUriLocal, but the "now playing" MediaItem built when actually playing something (car tap, phone-initiated play, folder-queue advance, direct local-track tap) did not, so the car's now-playing screen still went blank. Reuse the SAME artUriPara/artUriLocal fallback (already the project's one selection scheme, mirroring PluriStationArtFallback) at every "now playing" construction site: reproducirPorMediaId, ServicioAudio.reproducir (now via the extracted, unit-tested mediaItemParaEmisora), construirMediaItemColaLocal and reproducirPistaLocal. Guard the reverse direction too: emisoraDesdeMediaItem (extracted from the handler's private method, now unit-tested) only reflects artUri back into Emisora.favicon when it passes faviconUsable, so the phone UI's CachedNetworkImage widgets never attempt a doomed fetch of the car's android.resource:// fallback URI -- they keep falling back to PluriStationArtFallback exactly as before. --- lib/servicios/navegacion_auto.dart | 18 ++- lib/servicios/servicio_audio.dart | 68 ++++++--- test/servicios/navegacion_auto_test.dart | 98 ++++++++++++ .../servicio_audio_fallback_art_test.dart | 139 ++++++++++++++++++ 4 files changed, 298 insertions(+), 25 deletions(-) create mode 100644 test/servicios/servicio_audio_fallback_art_test.dart diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 84a78ef..46f53b7 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -836,10 +836,12 @@ Future reproducirPorMediaId( title: emisora.nombre, artist: emisora.pais ?? '', album: 'PluriWave', - artUri: - emisora.favicon != null && emisora.favicon!.isNotEmpty - ? Uri.tryParse(emisora.favicon!) - : null, + // Item 3: reuses [artUriPara] (the SAME fallback the browse tree's + // itemEmisora already applies) so the "now playing" media item never + // falls back to a blank tile — a real usable favicon still wins, a + // missing/unusable one gets the on-brand rotating drawable instead of + // `null`. + artUri: Uri.parse(artUriPara(emisora)), extras: {'uuid': emisora.uuid}, ); await reproducir(item); @@ -1143,6 +1145,11 @@ Future construirMediaItemColaLocal( id: contentUri, title: _tituloDesdeDocumentId(nodo.documentId), album: 'PluriWave', + // Item 3: a queued local track had NO artUri at all before — reuses + // [artUriLocal] (the SAME on-brand rotation the browse tree's + // `_itemLocal` already falls back to) so the car's now-playing screen + // never shows a blank tile for a track with no embedded art. + artUri: Uri.parse(artUriLocal(nodo.documentId)), extras: {'documentId': nodo.documentId}, ); } @@ -1322,6 +1329,9 @@ Future reproducirPistaLocal( id: pista.contentUri, title: pista.titulo, album: 'PluriWave', + // Item 3: same fallback as construirMediaItemColaLocal, for a track + // tapped directly (not via a folder-play queue). + artUri: Uri.parse(artUriLocal(pista.documentId)), extras: {'documentId': pista.documentId}, ); await reproducir(item); diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 2999cca..0545728 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -60,6 +60,46 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) { _fuenteMusicaLocalGlobal = fuente; } +/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android +/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a +/// station with no usable favicon gets the SAME on-brand rotating fallback +/// 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}) { + return MediaItem( + id: emisora.url, + title: localizedStationName(l10n, emisora.nombre), + artist: emisora.pais ?? '', + album: 'PluriWave', + artUri: Uri.parse(artUriPara(emisora)), + extras: {'uuid': emisora.uuid}, + ); +} + +/// Reconstructs the phone-side [Emisora] from the handler's current +/// [MediaItem] (item 3): gates `favicon` through [faviconUsable] +/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand +/// FALLBACK `artUri` (an `android.resource://` drawable, never a real +/// favicon) is never misread as a genuine station favicon — the phone UI's +/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty` +/// (not on `faviconUsable`'s scheme check), so without this guard they would +/// attempt a doomed network fetch of the fallback's non-http URI before +/// falling back to [PluriStationArtFallback] themselves. A genuine http(s) +/// favicon still round-trips exactly as before. Pure — no handler +/// dependency — unit-testable directly. +Emisora emisoraDesdeMediaItem(MediaItem mediaItem) { + final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; + final artUriTexto = mediaItem.artUri?.toString(); + return Emisora( + uuid: uuid, + nombre: mediaItem.title, + url: mediaItem.id, + pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null, + favicon: faviconUsable(artUriTexto) ? artUriTexto : null, + ); +} + /// Wrapper de alto nivel para el UI. class ServicioAudio { PluriWaveAudioHandler get _handler { @@ -94,19 +134,9 @@ class ServicioAudio { }); Future reproducir(Emisora emisora) async { - final item = MediaItem( - id: emisora.url, - title: localizedStationName( - lookupAppLocalizations(const Locale('es')), - emisora.nombre, - ), - artist: emisora.pais ?? '', - album: 'PluriWave', - artUri: - emisora.favicon != null && emisora.favicon!.isNotEmpty - ? Uri.tryParse(emisora.favicon!) - : null, - extras: {'uuid': emisora.uuid}, + final item = mediaItemParaEmisora( + emisora, + l10n: lookupAppLocalizations(const Locale('es')), ); await _handler.playMediaItem(item); } @@ -911,14 +941,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler } Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { - final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; - return Emisora( - uuid: uuid, - nombre: mediaItem.title, - url: mediaItem.id, - pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null, - favicon: mediaItem.artUri?.toString(), - ); + // Item 3: delegates to the top-level, unit-testable function so the + // `faviconUsable` guard (never reflect the on-brand fallback artUri + // back as a real favicon) is covered without instantiating the handler. + return emisoraDesdeMediaItem(mediaItem); } // ── Android Auto browsing (thin delegation to navegacion_auto.dart's diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index f7f6abc..4f19239 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2189,6 +2189,28 @@ void main() { }, ); + test( + 'item 3: el MediaItem de una pista local tocada directamente usa el ' + 'fallback de marca (artUriLocal), no queda sin artUri', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + MediaItem? recibido; + + await reproducirPistaLocal( + 'pista:doc1', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido!.artUri, isNotNull); + expect(recibido!.artUri.toString(), artUriLocal('doc1')); + }, + ); + test( 'id obsoleto/desconocido (uriContenidoDePista devuelve null) no ' 'llama a reproducir ni lanza excepción', @@ -2494,6 +2516,27 @@ void main() { expect(item, isNull); }, ); + + test( + 'item 3: el MediaItem de una pista en cola de "Reproducir carpeta" ' + 'usa el fallback de marca (artUriLocal), no queda sin artUri (antes ' + 'una pista en cola no tenía NINGÚN arte)', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + const nodo = NodoLocal( + documentId: 'doc1', + nombre: 'ignorado.mp3', + esDirectorio: false, + ); + + final item = await construirMediaItemColaLocal(nodo, fuente: fuente); + + expect(item!.artUri, isNotNull); + expect(item.artUri.toString(), artUriLocal('doc1')); + }, + ); }); group('ConstructorArbolAuto.itemEmisora', () { @@ -2866,6 +2909,61 @@ void main() { expect(llamadas, 0); }, ); + + test( + 'item 3: emisora SIN favicon usable -- el MediaItem "en reproducción" ' + 'usa el fallback de marca (artUriPara), no queda con artUri null ' + '(antes se perdía el arte al reproducir desde el auto)', + () async { + final emisora = _emisora( + uuid: 'uuid-sin-arte', + nombre: 'Radio sin logo', + favicon: null, + ); + final fuente = _FakeFuenteEmisorasAuto( + porUuidResultado: {emisora.uuid: emisora}, + ); + MediaItem? recibido; + + await reproducirPorMediaId( + 'emisora:${emisora.uuid}', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido, isNotNull); + expect(recibido!.artUri, isNotNull); + expect(recibido!.artUri.toString(), artUriPara(emisora)); + }, + ); + + test( + 'item 3: emisora CON favicon http(s) usable -- el MediaItem "en ' + 'reproducción" sigue usando ese favicon real, no el fallback', + () async { + final emisora = _emisora( + uuid: 'uuid-con-arte', + nombre: 'Radio con logo', + favicon: 'https://cdn.example.com/logo.png', + ); + final fuente = _FakeFuenteEmisorasAuto( + porUuidResultado: {emisora.uuid: emisora}, + ); + MediaItem? recibido; + + await reproducirPorMediaId( + 'emisora:${emisora.uuid}', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido!.artUri.toString(), emisora.favicon); + }, + ); }); // ── Item 1: Android Auto subfolder visibility ─────────────────────────── diff --git a/test/servicios/servicio_audio_fallback_art_test.dart b/test/servicios/servicio_audio_fallback_art_test.dart new file mode 100644 index 0000000..306f746 --- /dev/null +++ b/test/servicios/servicio_audio_fallback_art_test.dart @@ -0,0 +1,139 @@ +import 'dart:ui' show Locale; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// Item 3 (Android Auto fallback artwork) — the pure, handler-independent +/// half of the fix. `PluriWaveAudioHandler` cannot be instantiated in unit +/// tests (a real `just_audio.AudioPlayer` requires platform MethodChannels, +/// confirmed by `servicio_audio_source_switch_test.dart`), so the actual +/// "now playing" MediaItem construction and the reverse Emisora +/// reconstruction are extracted as pure top-level functions here, exactly +/// like `debeReaplicarEcualizador` was extracted for the EQ re-apply fix. +void main() { + final l10n = lookupAppLocalizations(const Locale('es')); + + group('mediaItemParaEmisora (item 3)', () { + test( + 'estación SIN favicon usable: el MediaItem usa el fallback de marca ' + '(artUriPara), no queda con artUri null', + () { + const emisora = Emisora( + uuid: 'uuid-sin-arte', + nombre: 'Radio sin logo', + url: 'https://stream.demo/sin-logo', + ); + + final item = mediaItemParaEmisora(emisora, l10n: l10n); + + expect(item.artUri, isNotNull); + expect(item.artUri.toString(), artUriPara(emisora)); + }, + ); + + test( + 'estación CON favicon http(s) usable: el MediaItem sigue usando ese ' + 'favicon real, no el fallback', + () { + const emisora = Emisora( + uuid: 'uuid-con-arte', + nombre: 'Radio con logo', + url: 'https://stream.demo/con-logo', + favicon: 'https://cdn.example.com/logo.png', + ); + + final item = mediaItemParaEmisora(emisora, l10n: l10n); + + expect(item.artUri.toString(), emisora.favicon); + }, + ); + + test('preserva id, artist y extras.uuid como antes', () { + const emisora = Emisora( + uuid: 'uuid-forma', + nombre: 'Radio Forma', + url: 'https://stream.demo/forma', + pais: 'Argentina', + ); + + final item = mediaItemParaEmisora(emisora, l10n: l10n); + + expect(item.id, emisora.url); + expect(item.artist, 'Argentina'); + expect(item.album, 'PluriWave'); + expect(item.extras?['uuid'], emisora.uuid); + }); + }); + + group('emisoraDesdeMediaItem (item 3 — no phone-UI regression)', () { + test( + 'artUri de marca (android.resource://…, no http) NUNCA se refleja ' + 'como favicon -- evitaría un intento de red inválido en ' + 'CachedNetworkImage del lado telefono', + () { + final mediaItem = MediaItem( + id: 'https://stream.demo/sin-logo', + title: 'Radio sin logo', + artUri: Uri.parse( + 'android.resource://es.freetimelab.pluriwave/drawable/' + 'station_art_aurora', + ), + extras: const {'uuid': 'uuid-sin-arte'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.favicon, isNull); + }, + ); + + test( + 'artUri http(s) real SÍ se refleja como favicon (comportamiento ' + 'previo preservado)', + () { + final mediaItem = MediaItem( + id: 'https://stream.demo/con-logo', + title: 'Radio con logo', + artUri: Uri.parse('https://cdn.example.com/logo.png'), + extras: const {'uuid': 'uuid-con-arte'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.favicon, 'https://cdn.example.com/logo.png'); + }, + ); + + test('sin artUri: favicon queda null, sin lanzar', () { + final mediaItem = MediaItem( + id: 'https://stream.demo/sin-arturi', + title: 'Radio', + extras: const {'uuid': 'uuid-x'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.favicon, isNull); + }); + + test('preserva uuid, nombre, url y pais como antes', () { + final mediaItem = MediaItem( + id: 'https://stream.demo/forma', + title: 'Radio Forma', + artist: 'Argentina', + extras: const {'uuid': 'uuid-forma'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.uuid, 'uuid-forma'); + expect(emisora.nombre, 'Radio Forma'); + expect(emisora.url, mediaItem.id); + expect(emisora.pais, 'Argentina'); + }); + }); +} From 9eff7604626ac50147163f5da5d5ffcd1a248b83 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:54:05 +0200 Subject: [PATCH 4/6] feat(auto): equalizer enable/disable and preset cycling from the car Expose the equalizer's on/off toggle and preset choice as PlaybackStateCompat custom actions on the now-playing screen. The redesign's removal of the in-car equalizer FOLDER from the browse tree stays as-is (2403da3) -- this is a different surface (playback screen custom actions, not a browse folder) and does not reintroduce it. Deliberately just 2 actions -- 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. Both reuse the existing setEcualizadorActivo/aplicarPreset entry points (the same ones EstadoEcualizador's phone settings screen uses), so a car tap and a phone tap behave identically and both keep the action labels in sync. Reuses the bundled ic_stat_pluriwave drawable (the notification's own equalizer-bars icon) -- zero new native assets. The 5-band constraint is untouched. New pure, unit-tested functions in servicio_audio.dart: presetSiguiente, nombrePresetVisible, controlesEcualizadorPersonalizados. New ARB keys (eqCustomActionEnableLabel/DisableLabel/PresetLabel) across all 13 locales, regenerated via flutter gen-l10n. --- lib/l10n/app_ar.arb | 12 +- lib/l10n/app_bn.arb | 12 +- lib/l10n/app_de.arb | 12 +- lib/l10n/app_en.arb | 12 +- lib/l10n/app_es.arb | 12 +- lib/l10n/app_fr.arb | 12 +- lib/l10n/app_hi.arb | 12 +- lib/l10n/app_id.arb | 12 +- lib/l10n/app_it.arb | 12 +- lib/l10n/app_ja.arb | 12 +- lib/l10n/app_pt.arb | 12 +- lib/l10n/app_ru.arb | 12 +- lib/l10n/app_zh.arb | 12 +- lib/l10n/gen/app_localizations.dart | 18 ++ lib/l10n/gen/app_localizations_ar.dart | 11 + lib/l10n/gen/app_localizations_bn.dart | 11 + lib/l10n/gen/app_localizations_de.dart | 11 + lib/l10n/gen/app_localizations_en.dart | 11 + lib/l10n/gen/app_localizations_es.dart | 11 + lib/l10n/gen/app_localizations_fr.dart | 11 + lib/l10n/gen/app_localizations_hi.dart | 11 + lib/l10n/gen/app_localizations_id.dart | 11 + lib/l10n/gen/app_localizations_it.dart | 11 + lib/l10n/gen/app_localizations_ja.dart | 11 + lib/l10n/gen/app_localizations_pt.dart | 11 + lib/l10n/gen/app_localizations_ru.dart | 11 + lib/l10n/gen/app_localizations_zh.dart | 11 + lib/servicios/servicio_audio.dart | 227 +++++++++++++++--- ...servicio_audio_eq_custom_actions_test.dart | 189 +++++++++++++++ 29 files changed, 688 insertions(+), 45 deletions(-) create mode 100644 test/servicios/servicio_audio_eq_custom_actions_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 92199c0..deb9d1b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "عالمك، على الهواء مباشرة", "yourStationsTitle": "محطاتك", "nowListeningLabel": "الاستماع الآن", - "popularNowTitle": "الأكثر شيوعًا الآن" + "popularNowTitle": "الأكثر شيوعًا الآن", + "eqCustomActionEnableLabel": "تفعيل الموازن", + "eqCustomActionDisableLabel": "إيقاف الموازن", + "eqCustomActionPresetLabel": "الإعداد المسبق: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index ec8b0f0..cf70e01 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "আপনার বিশ্ব, সরাসরি", "yourStationsTitle": "আপনার স্টেশন", "nowListeningLabel": "এখন শোনা হচ্ছে", - "popularNowTitle": "এখন জনপ্রিয়" + "popularNowTitle": "এখন জনপ্রিয়", + "eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন", + "eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন", + "eqCustomActionPresetLabel": "প্রিসেট: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 12ddab6..41067df 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "Deine Welt, live", "yourStationsTitle": "Deine Sender", "nowListeningLabel": "Läuft gerade", - "popularNowTitle": "Jetzt beliebt" + "popularNowTitle": "Jetzt beliebt", + "eqCustomActionEnableLabel": "Equalizer aktivieren", + "eqCustomActionDisableLabel": "Equalizer deaktivieren", + "eqCustomActionPresetLabel": "Voreinstellung: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f57b44d..8b80932 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -830,5 +830,15 @@ "welcomeBullet2Subtitle": "Your favorites and local music in the car", "welcomeBullet3Title": "Music alarms", "welcomeBullet3Subtitle": "With gradual volume rise and vacation mode", - "welcomeCtaLabel": "Start listening" + "welcomeCtaLabel": "Start listening", + "eqCustomActionEnableLabel": "Enable equalizer", + "eqCustomActionDisableLabel": "Disable equalizer", + "eqCustomActionPresetLabel": "Preset: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 835e4ac..0ff53ee 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -789,5 +789,15 @@ "welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto", "welcomeBullet3Title": "Alarmas musicales", "welcomeBullet3Subtitle": "Con subida progresiva y modo vacaciones", - "welcomeCtaLabel": "Empezar a escuchar" + "welcomeCtaLabel": "Empezar a escuchar", + "eqCustomActionEnableLabel": "Activar ecualizador", + "eqCustomActionDisableLabel": "Desactivar ecualizador", + "eqCustomActionPresetLabel": "Preset: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bb4d7a9..95e56dd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "Votre monde, en direct", "yourStationsTitle": "Vos stations", "nowListeningLabel": "En cours d'écoute", - "popularNowTitle": "Populaire maintenant" + "popularNowTitle": "Populaire maintenant", + "eqCustomActionEnableLabel": "Activer l'égaliseur", + "eqCustomActionDisableLabel": "Désactiver l'égaliseur", + "eqCustomActionPresetLabel": "Préréglage : {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b9e4a9c..f770523 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "आपकी दुनिया, लाइव", "yourStationsTitle": "आपके स्टेशन", "nowListeningLabel": "अभी सुन रहे हैं", - "popularNowTitle": "अभी लोकप्रिय" + "popularNowTitle": "अभी लोकप्रिय", + "eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें", + "eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें", + "eqCustomActionPresetLabel": "प्रीसेट: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b16c871..54de15f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "Duniamu, secara langsung", "yourStationsTitle": "Stasiun Anda", "nowListeningLabel": "Sedang mendengarkan", - "popularNowTitle": "Populer sekarang" + "popularNowTitle": "Populer sekarang", + "eqCustomActionEnableLabel": "Aktifkan equalizer", + "eqCustomActionDisableLabel": "Nonaktifkan equalizer", + "eqCustomActionPresetLabel": "Prasetel: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 8e26513..b9196bd 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "Il tuo mondo, in diretta", "yourStationsTitle": "Le tue emittenti", "nowListeningLabel": "In ascolto ora", - "popularNowTitle": "Popolari ora" + "popularNowTitle": "Popolari ora", + "eqCustomActionEnableLabel": "Attiva equalizzatore", + "eqCustomActionDisableLabel": "Disattiva equalizzatore", + "eqCustomActionPresetLabel": "Preset attivo: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 432b52d..5bab00d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "あなたの世界を、ライブで", "yourStationsTitle": "あなたの局", "nowListeningLabel": "再生中", - "popularNowTitle": "今人気" + "popularNowTitle": "今人気", + "eqCustomActionEnableLabel": "イコライザーをオンにする", + "eqCustomActionDisableLabel": "イコライザーをオフにする", + "eqCustomActionPresetLabel": "プリセット: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 545a927..11d474a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "Seu mundo, ao vivo", "yourStationsTitle": "Suas estações", "nowListeningLabel": "Ouvindo agora", - "popularNowTitle": "Populares agora" + "popularNowTitle": "Populares agora", + "eqCustomActionEnableLabel": "Ativar equalizador", + "eqCustomActionDisableLabel": "Desativar equalizador", + "eqCustomActionPresetLabel": "Predefinição: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 98166f2..701f3cb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "Ваш мир, в прямом эфире", "yourStationsTitle": "Ваши станции", "nowListeningLabel": "Сейчас слушаете", - "popularNowTitle": "Популярно сейчас" + "popularNowTitle": "Популярно сейчас", + "eqCustomActionEnableLabel": "Включить эквалайзер", + "eqCustomActionDisableLabel": "Выключить эквалайзер", + "eqCustomActionPresetLabel": "Пресет: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dc2925c..5fe58a1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "你的世界,直播中", "yourStationsTitle": "你的电台", "nowListeningLabel": "正在收听", - "popularNowTitle": "当前热门" + "popularNowTitle": "当前热门", + "eqCustomActionEnableLabel": "启用均衡器", + "eqCustomActionDisableLabel": "关闭均衡器", + "eqCustomActionPresetLabel": "预设:{preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 1f62743..fca451f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3049,6 +3049,24 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Empezar a escuchar'** String get welcomeCtaLabel; + + /// No description provided for @eqCustomActionEnableLabel. + /// + /// In es, this message translates to: + /// **'Activar ecualizador'** + String get eqCustomActionEnableLabel; + + /// No description provided for @eqCustomActionDisableLabel. + /// + /// In es, this message translates to: + /// **'Desactivar ecualizador'** + String get eqCustomActionDisableLabel; + + /// No description provided for @eqCustomActionPresetLabel. + /// + /// In es, this message translates to: + /// **'Preset: {preset}'** + String eqCustomActionPresetLabel(String preset); } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 722038b..651c617 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1676,4 +1676,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get welcomeCtaLabel => 'ابدأ الاستماع'; + + @override + String get eqCustomActionEnableLabel => 'تفعيل الموازن'; + + @override + String get eqCustomActionDisableLabel => 'إيقاف الموازن'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'الإعداد المسبق: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 5627416..7474095 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1685,4 +1685,15 @@ class AppLocalizationsBn extends AppLocalizations { @override String get welcomeCtaLabel => 'শোনা শুরু করুন'; + + @override + String get eqCustomActionEnableLabel => 'ইকুয়ালাইজার চালু করুন'; + + @override + String get eqCustomActionDisableLabel => 'ইকুয়ালাইজার বন্ধ করুন'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'প্রিসেট: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 4c63b22..295812a 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1698,4 +1698,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomeCtaLabel => 'Jetzt hören'; + + @override + String get eqCustomActionEnableLabel => 'Equalizer aktivieren'; + + @override + String get eqCustomActionDisableLabel => 'Equalizer deaktivieren'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Voreinstellung: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index a50957d..f02de76 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1678,4 +1678,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get welcomeCtaLabel => 'Start listening'; + + @override + String get eqCustomActionEnableLabel => 'Enable equalizer'; + + @override + String get eqCustomActionDisableLabel => 'Disable equalizer'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Preset: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 55857ea..fbb6307 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1692,4 +1692,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get welcomeCtaLabel => 'Empezar a escuchar'; + + @override + String get eqCustomActionEnableLabel => 'Activar ecualizador'; + + @override + String get eqCustomActionDisableLabel => 'Desactivar ecualizador'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Preset: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 1232e33..f071a13 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1701,4 +1701,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get welcomeCtaLabel => 'Commencer à écouter'; + + @override + String get eqCustomActionEnableLabel => 'Activer l\'égaliseur'; + + @override + String get eqCustomActionDisableLabel => 'Désactiver l\'égaliseur'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Préréglage : $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 3655468..6f62753 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1680,4 +1680,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get welcomeCtaLabel => 'सुनना शुरू करें'; + + @override + String get eqCustomActionEnableLabel => 'इक्वलाइज़र चालू करें'; + + @override + String get eqCustomActionDisableLabel => 'इक्वलाइज़र बंद करें'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'प्रीसेट: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index a374b37..2b62c6a 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1688,4 +1688,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get welcomeCtaLabel => 'Mulai mendengarkan'; + + @override + String get eqCustomActionEnableLabel => 'Aktifkan equalizer'; + + @override + String get eqCustomActionDisableLabel => 'Nonaktifkan equalizer'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Prasetel: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index d1c3a1d..68bc853 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1700,4 +1700,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get welcomeCtaLabel => 'Inizia ad ascoltare'; + + @override + String get eqCustomActionEnableLabel => 'Attiva equalizzatore'; + + @override + String get eqCustomActionDisableLabel => 'Disattiva equalizzatore'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Preset attivo: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 1a01bd9..bec733e 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1631,4 +1631,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get welcomeCtaLabel => '聴き始める'; + + @override + String get eqCustomActionEnableLabel => 'イコライザーをオンにする'; + + @override + String get eqCustomActionDisableLabel => 'イコライザーをオフにする'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'プリセット: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index d6c92e1..fa10bc0 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1688,4 +1688,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get welcomeCtaLabel => 'Começar a ouvir'; + + @override + String get eqCustomActionEnableLabel => 'Ativar equalizador'; + + @override + String get eqCustomActionDisableLabel => 'Desativar equalizador'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Predefinição: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 01054c2..7b6b837 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1692,4 +1692,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get welcomeCtaLabel => 'Начать слушать'; + + @override + String get eqCustomActionEnableLabel => 'Включить эквалайзер'; + + @override + String get eqCustomActionDisableLabel => 'Выключить эквалайзер'; + + @override + String eqCustomActionPresetLabel(String preset) { + return 'Пресет: $preset'; + } } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index fc7826b..6c97bc2 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1623,4 +1623,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get welcomeCtaLabel => '开始收听'; + + @override + String get eqCustomActionEnableLabel => '启用均衡器'; + + @override + String get eqCustomActionDisableLabel => '关闭均衡器'; + + @override + String eqCustomActionPresetLabel(String preset) { + return '预设:$preset'; + } } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 0545728..89139ce 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -100,6 +100,91 @@ Emisora emisoraDesdeMediaItem(MediaItem mediaItem) { ); } +/// Custom-action names for the equalizer's `PlaybackStateCompat` custom +/// actions on the now-playing screen (Design "EQ custom actions", item 4). +/// Public consts so tests and this file's own `customAction` dispatch share +/// the exact same literals; distinct from every browse-tree media-id prefix +/// in `navegacion_auto.dart` (they live in a completely different +/// `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 +/// after the last one. When [actual] is not found in [presets] (e.g. a +/// user-tweaked "Personalizado" preset from `EstadoEcualizador.cambiarBanda`), +/// starts from the FIRST preset rather than throwing — cycling from an +/// unknown state always lands somewhere sane. Pure, no I/O. +/// +/// [presets] defaults to [PresetEcualizador.presets] — not a literal default +/// value, since that field is `static final` (not `const`) and Dart default +/// parameter values must be compile-time constants. +PresetEcualizador presetSiguiente( + PresetEcualizador actual, { + List? presets, +}) { + final lista = presets ?? PresetEcualizador.presets; + final indice = lista.indexWhere((p) => p == actual); + if (indice == -1) return lista.first; + return lista[(indice + 1) % lista.length]; +} + +/// Localizes a preset's raw `nombre` for the equalizer custom action's +/// label (Design "EQ custom actions", item 4) — mirrors +/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated +/// rather than shared: that file is UI-widget layer, this one is the +/// service/handler layer, and the mapping is a single small switch, not +/// worth a cross-layer import for). An unrecognized name (e.g. a future +/// user-named custom preset) falls through to the raw name verbatim. +String nombrePresetVisible(AppLocalizations l10n, String nombre) { + return switch (nombre) { + 'Flat' => l10n.equalizerPresetFlat, + 'Rock' => l10n.equalizerPresetRock, + 'Pop' => l10n.equalizerPresetPop, + 'Bass Boost' => l10n.equalizerPresetBassBoost, + 'Jazz' => l10n.equalizerPresetJazz, + 'Voz' => l10n.equalizerPresetVoice, + 'Personalizado' => l10n.equalizerPresetCustom, + _ => 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. +/// 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. +List 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, + name: accionEqToggle, + ), + MediaControl.custom( + androidIcon: 'drawable/ic_stat_pluriwave', + label: l10n.eqCustomActionPresetLabel( + nombrePresetVisible(l10n, presetActual.nombre), + ), + name: accionEqPresetSiguiente, + ), + ]; +} + /// Wrapper de alto nivel para el UI. class ServicioAudio { PluriWaveAudioHandler get _handler { @@ -319,12 +404,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler final colaActiva = _colaLocal != null; playbackState.add( playbackState.value.copyWith( - controls: [ - if (colaActiva) MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.stop, - if (colaActiva) MediaControl.skipToNext, - ], + controls: _controlesTransporte( + colaActiva: colaActiva, + playing: playing, + ), systemActions: { MediaAction.seek, MediaAction.stop, @@ -369,6 +452,49 @@ class PluriWaveAudioHandler extends BaseAudioHandler }); } + /// The full transport `controls` list for a `playbackState` push (item 4): + /// the existing skip/play-pause/stop set, plus the equalizer's custom + /// actions appended at the end. Appending (rather than interleaving) keeps + /// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext] + /// at their existing indices 0-3, so `androidCompactActionIndices` + /// (`[colaActiva ? 1 : 0]`) stays correct unchanged. + List _controlesTransporte({ + required bool colaActiva, + required bool playing, + }) => [ + if (colaActiva) MediaControl.skipToPrevious, + if (playing) MediaControl.pause else MediaControl.play, + MediaControl.stop, + if (colaActiva) MediaControl.skipToNext, + ..._controlesEqPersonalizados(), + ]; + + List _controlesEqPersonalizados() => + controlesEcualizadorPersonalizados( + disponible: _eqDisponible, + activo: _ecualizadorActivo, + presetActual: _presetActual, + l10n: _textos, + ); + + /// Re-pushes `playbackState` with a freshly built controls list (item 4): + /// called whenever EQ availability/enabled/preset state changes outside a + /// player-state transition (a custom-action tap, or a phone-side preset/ + /// toggle change), so the equalizer custom actions' label and current- + /// preset name stay in sync on the now-playing screen without waiting for + /// an unrelated player event. Idempotent and cheap (no native calls) — + /// safe to call from any EQ state-changing path. + void _actualizarControlesEq() { + playbackState.add( + playbackState.value.copyWith( + controls: _controlesTransporte( + colaActiva: _colaLocal != null, + playing: playbackState.value.playing, + ), + ), + ); + } + /// Gestiona cualquier error de reproducción de ExoPlayer. /// /// Network-class failures while the user still intends to play enter the @@ -754,6 +880,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler } catch (_) { _eqDisponible = false; } + // Item 4: an availability flip (e.g. a station switch that lands on a + // device without the native Equalizer effect) must show/hide the EQ + // custom actions immediately, not wait for a coincidental later + // player-state event. + _actualizarControlesEq(); } /// Pure re-apply decision for a native session-id emission. No side effects. @@ -772,25 +903,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// Aplica un preset al ecualizador nativo Android. Future aplicarPreset(PresetEcualizador preset) async { _presetActual = preset; - if (!_eqDisponible) return; - try { - await _eq.setEnabled(_ecualizadorActivo); - if (!_ecualizadorActivo) return; - final params = await _eq.parameters; - for ( - int i = 0; - i < params.bands.length && i < preset.bandas.length; - i++ - ) { - await params.bands[i].setGain( - _mapearGananciaNativa( - preset.bandas[i], - minDecibels: params.minDecibels, - maxDecibels: params.maxDecibels, - ), - ); - } - } catch (_) {} + if (_eqDisponible) { + try { + await _eq.setEnabled(_ecualizadorActivo); + if (_ecualizadorActivo) { + final params = await _eq.parameters; + for ( + int i = 0; + i < params.bands.length && i < preset.bandas.length; + i++ + ) { + await params.bands[i].setGain( + _mapearGananciaNativa( + preset.bandas[i], + minDecibels: params.minDecibels, + maxDecibels: params.maxDecibels, + ), + ); + } + } + } catch (_) {} + } + // Item 4: keeps the EQ custom action's preset-cycle label in sync + // regardless of WHO changed the preset (a car customAction tap or the + // phone settings screen via EstadoEcualizador) — single chokepoint. + _actualizarControlesEq(); } /// Ajusta una banda individual. @@ -826,13 +963,18 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future setEcualizadorActivo(bool activo) async { _ecualizadorActivo = activo; - if (!_eqDisponible) return; - try { - await _eq.setEnabled(activo); - if (activo) { - await aplicarPreset(_presetActual); - } - } catch (_) {} + if (_eqDisponible) { + try { + await _eq.setEnabled(activo); + if (activo) { + await aplicarPreset(_presetActual); + } + } catch (_) {} + } + // Item 4: keeps the EQ custom action's on/off label in sync regardless + // of WHO toggled it (a car customAction tap or the phone settings + // screen via EstadoEcualizador). + _actualizarControlesEq(); } Future setVolumen(double vol) async { @@ -929,6 +1071,27 @@ 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 + /// 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. + @override + Future customAction( + String name, [ + Map? extras, + ]) async { + switch (name) { + case accionEqToggle: + await setEcualizadorActivo(!_ecualizadorActivo); + case accionEqPresetSiguiente: + await aplicarPreset(presetSiguiente(_presetActual)); + } + } + @override Future onTaskRemoved() async { await stop(); diff --git a/test/servicios/servicio_audio_eq_custom_actions_test.dart b/test/servicios/servicio_audio_eq_custom_actions_test.dart new file mode 100644 index 0000000..2de6d45 --- /dev/null +++ b/test/servicios/servicio_audio_eq_custom_actions_test.dart @@ -0,0 +1,189 @@ +import 'dart:ui' show Locale; + +import 'package:audio_service/audio_service.dart'; +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/servicio_audio.dart'; + +/// Item 4 (Android Auto: equalizer custom actions) — the pure, handler- +/// independent half of the fix. `PluriWaveAudioHandler` cannot be +/// instantiated in unit tests (a real `just_audio.AudioPlayer` requires +/// platform MethodChannels), so the preset-cycling decision, the preset-name +/// localization and the `MediaControl` list construction are extracted as +/// pure top-level functions here. The handler's own `customAction` dispatch +/// and `playbackState` wiring are static-review-only, same as the existing +/// EQ re-apply/session-id wiring. +void main() { + final l10n = lookupAppLocalizations(const Locale('es')); + + 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, + ); + }); + + test('wraps around after the last preset', () { + expect( + presetSiguiente(PresetEcualizador.presets.last), + PresetEcualizador.presets.first, + ); + }); + + 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, + ); + }, + ); + + test('respects an injected presets list instead of the default 6', () { + final propios = [PresetEcualizador.jazz, PresetEcualizador.voz]; + + expect( + presetSiguiente(PresetEcualizador.jazz, presets: propios), + PresetEcualizador.voz, + ); + expect( + presetSiguiente(PresetEcualizador.voz, presets: propios), + PresetEcualizador.jazz, + ); + }); + }); + + group('nombrePresetVisible (item 4)', () { + test('maps every factory preset name to its localized ARB string', () { + expect(nombrePresetVisible(l10n, 'Flat'), l10n.equalizerPresetFlat); + expect(nombrePresetVisible(l10n, 'Rock'), l10n.equalizerPresetRock); + expect(nombrePresetVisible(l10n, 'Pop'), l10n.equalizerPresetPop); + expect( + nombrePresetVisible(l10n, 'Bass Boost'), + l10n.equalizerPresetBassBoost, + ); + expect(nombrePresetVisible(l10n, 'Jazz'), l10n.equalizerPresetJazz); + expect(nombrePresetVisible(l10n, 'Voz'), l10n.equalizerPresetVoice); + expect( + nombrePresetVisible(l10n, 'Personalizado'), + l10n.equalizerPresetCustom, + ); + }); + + test('an unrecognized name falls through verbatim', () { + expect(nombrePresetVisible(l10n, 'Mi Preset Guardado'), 'Mi Preset Guardado'); + }); + }); + + group('controlesEcualizadorPersonalizados (item 4)', () { + test('empty when the equalizer is not available on this device', () { + 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, + ); + + expect(controles, hasLength(2)); + expect(controles.every((c) => c.action == MediaAction.custom), isTrue); + }, + ); + + test('toggle label reflects ON -> shows "disable" action', () { + final controles = controlesEcualizadorPersonalizados( + disponible: true, + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ); + final toggle = controles.firstWhere( + (c) => c.customAction?.name == accionEqToggle, + ); + + expect(toggle.label, l10n.eqCustomActionDisableLabel); + }); + + test('toggle label reflects OFF -> shows "enable" action', () { + final controles = controlesEcualizadorPersonalizados( + disponible: true, + activo: false, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ); + final toggle = controles.firstWhere( + (c) => c.customAction?.name == accionEqToggle, + ); + + expect(toggle.label, l10n.eqCustomActionEnableLabel); + }); + + test('preset-cycle label shows the CURRENT preset localized name', () { + final controles = 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( + disponible: true, + activo: true, + presetActual: PresetEcualizador.flat, + l10n: l10n, + ); + + expect( + controles.every((c) => c.androidIcon == 'drawable/ic_stat_pluriwave'), + isTrue, + ); + }); + }); + + 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))); + }); + }); +} From 491585ad1217c9727624bd202a49bc9009a91a14 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:56:37 +0200 Subject: [PATCH 5/6] fix(eq): re-apply the equalizer after an audio-focus interruption The equalizer stopped applying after another app interrupted audio (e.g. a navigation app's voice prompt): play a station with EQ working, let the prompt speak, resume -- the audio sounds flat until the station is re-tapped. debeReaplicarEcualizador only re-attaches the equalizer when the native player session id actually changes. A short transient interruption keeps the SAME session (no id rotation), so that trigger never fires, while Android's AudioEffect framework can let a higher-priority client silently disable this app's effect instance in the meantime. Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a thin delegate to the existing _activarEcualizador() (already the correct idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it on resume-from-pause (after reanudar()) and on un-duck (after setAtenuado(false)) -- additive to the existing session-id trigger, not a replacement. The method takes no argument, so it can only re-assert whatever enabled/disabled state the handler already holds -- an interruption cycle with the equalizer OFF stays OFF. --- lib/servicios/servicio_audio.dart | 10 + lib/servicios/servicio_audio_session.dart | 21 ++ .../servicio_audio_eq_reapply_test.dart | 190 ++++++++++++++++++ .../servicio_audio_session_test.dart | 7 + 4 files changed, 228 insertions(+) diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 89139ce..7f53582 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -1006,6 +1006,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _player.setVolume(_volumenEfectivo); } + /// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the + /// existing [_activarEcualizador] (already does the correct idempotent + /// `setEnabled` + re-push-gains work, already re-asserts the CURRENT + /// [_ecualizadorActivo] rather than forcing it on). Called by + /// [ServicioAudioSession] on resume-from-pause and on un-duck — see that + /// interface member's doc for why the existing session-id-change trigger + /// misses this case. + @override + Future reaplicarEcualizador() => _activarEcualizador(); + @override Future play() { _intencionReproducir = true; diff --git a/lib/servicios/servicio_audio_session.dart b/lib/servicios/servicio_audio_session.dart index 61047c9..2262166 100644 --- a/lib/servicios/servicio_audio_session.dart +++ b/lib/servicios/servicio_audio_session.dart @@ -20,6 +20,20 @@ abstract class ObjetivoAudioInterrumpible { /// Temporarily lowers ("ducks") the output volume without pausing. Future setAtenuado(bool atenuado); + + /// Re-attaches the equalizer effect and re-pushes the current preset's + /// gains (fix "EQ Re-Apply After Audio-Focus Interruption"). Called after + /// resuming from a transient interruption pause and after un-ducking, + /// because Android's AudioEffect framework can let a higher-priority + /// client silently disable this app's effect instance while the + /// underlying player session id never changes — the existing session-id + /// rotation trigger (`ServicioAudio.debeReaplicarEcualizador`) therefore + /// never fires for a SHORT interruption (e.g. a nav-app voice prompt). + /// Idempotent and cheap (a `setEnabled` plus band `setGain` calls); takes + /// no argument by design — it re-asserts whatever enabled/disabled state + /// the handler ALREADY holds, so a caller here can never force the + /// equalizer on. Never restarts or repositions playback. + Future reaplicarEcualizador(); } /// Wrapper around `package:audio_session` (S3-R1): configures the session @@ -84,11 +98,18 @@ class ServicioAudioSession { switch (evento.type) { case AudioInterruptionType.duck: await _objetivo.setAtenuado(false); + // Un-ducking never rotates the native player session id, so the + // session-id-change trigger never fires for this case — re-assert + // here too (belt-and-braces, additive to that trigger). + await _objetivo.reaplicarEcualizador(); case AudioInterruptionType.pause: // Transient loss ended and the OS says we may resume. if (_pausadoPorInterrupcion) { _pausadoPorInterrupcion = false; await _objetivo.reanudar(); + // Same rationale as the duck branch above: a short transient + // interruption keeps the SAME player session id. + await _objetivo.reaplicarEcualizador(); } case AudioInterruptionType.unknown: // Permanent focus loss: never auto-resume. diff --git a/test/servicios/servicio_audio_eq_reapply_test.dart b/test/servicios/servicio_audio_eq_reapply_test.dart index a2f5134..48469d8 100644 --- a/test/servicios/servicio_audio_eq_reapply_test.dart +++ b/test/servicios/servicio_audio_eq_reapply_test.dart @@ -1,5 +1,7 @@ +import 'package:audio_session/audio_session.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/servicios/servicio_audio_session.dart'; /// EQ audio-focus re-apply — pure decision predicate truth table. /// @@ -96,4 +98,192 @@ void main() { ); }); }); + + // ── EQ re-apply after a SHORT audio-focus interruption ────────────────── + // debeReaplicarEcualizador only fires on a session-id CHANGE. A short + // transient interruption (a nav-app voice prompt) keeps the SAME player + // session id, so that trigger never fires and the equalizer stays + // silently disabled after Android lets another app's AudioEffect steal + // control. Fix: re-assert the equalizer on resume-from-pause and on + // un-duck too, via a new no-arg ObjetivoAudioInterrumpible.reaplicarEcualizador() + // that the handler implements as a thin delegate to the existing + // _activarEcualizador() (setEnabled + band gains, already correct). + // + // ServicioAudioSession is the orchestration layer under test here (the + // same layer servicio_audio_session_test.dart already covers) -- it is + // fully unit-testable, unlike PluriWaveAudioHandler itself. + group( + 'ServicioAudioSession re-applies the equalizer on interruption resume ' + '(no session-id change involved)', + () { + test( + 'a pause-interruption cycle (begin -> end/resume) calls ' + 'reaplicarEcualizador exactly once, AFTER reanudar()', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.pause), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.pause), + ); + + expect(objetivo.reaplicaciones, 1); + expect( + objetivo.eventos, + ['pausar', 'reanudar', 'reaplicar'], + reason: + 'the re-apply must happen on RESUME, after reanudar() -- ' + 'never before, never on the begin/pause side', + ); + }, + ); + + test( + 'a duck cycle (begin -> end/un-duck) calls reaplicarEcualizador ' + 'exactly once, AFTER setAtenuado(false)', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.duck), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.duck), + ); + + expect(objetivo.reaplicaciones, 1); + expect( + objetivo.eventos, + ['atenuado:true', 'atenuado:false', 'reaplicar'], + reason: + 'the re-apply must happen on UN-DUCK, after ' + 'setAtenuado(false)', + ); + expect(objetivo.pausas, 0, reason: 'a duck never pauses'); + }, + ); + + test( + 'with the equalizer switched OFF by the user, an interruption ' + 'cycle still only calls the SAME parameterless reassert -- ' + 'ServicioAudioSession has no way to force it on', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true + ..eqActivo = false; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.pause), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.pause), + ); + + expect(objetivo.reaplicaciones, 1); + expect( + objetivo.estadosReaplicados, + [false], + reason: + 'reaplicarEcualizador takes no boolean argument -- it can ' + 'only ask the handler to reassert whatever state it ' + 'ALREADY holds, never flip it on', + ); + }, + ); + + test( + 'end without a prior begin/pause never calls reaplicarEcualizador ' + '(mirrors "end sin pausa previa" -- no resume happened)', + () async { + final objetivo = _ObjetivoFake(); + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.pause), + ); + + expect(objetivo.reaplicaciones, 0); + }, + ); + + test( + 'a permanent (unknown-type) focus loss never calls ' + 'reaplicarEcualizador -- there is no resume to re-assert after', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.unknown), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.unknown), + ); + + expect(objetivo.reaplicaciones, 0); + }, + ); + }, + ); +} + +class _ObjetivoFake implements ObjetivoAudioInterrumpible { + bool intencion = false; + bool reproduciendo = false; + bool eqActivo = true; + int pausas = 0; + int reaplicaciones = 0; + final List atenuaciones = []; + final List estadosReaplicados = []; + + /// Ordering log shared across every method — proves reaplicarEcualizador + /// fires at the EXACT point in the sequence the fix requires (after + /// reanudar()/setAtenuado(false)), not merely "at some point". + final List eventos = []; + + @override + bool get intencionReproducir => intencion; + + @override + bool get estaReproduciendo => reproduciendo; + + @override + Future pausar() async { + pausas++; + reproduciendo = false; + intencion = false; + eventos.add('pausar'); + } + + @override + Future reanudar() async { + reproduciendo = true; + intencion = true; + eventos.add('reanudar'); + } + + @override + Future setAtenuado(bool atenuado) async { + atenuaciones.add(atenuado); + eventos.add('atenuado:$atenuado'); + } + + @override + Future reaplicarEcualizador() async { + reaplicaciones++; + estadosReaplicados.add(eqActivo); + eventos.add('reaplicar'); + } } diff --git a/test/servicios/servicio_audio_session_test.dart b/test/servicios/servicio_audio_session_test.dart index bc8798c..4f55487 100644 --- a/test/servicios/servicio_audio_session_test.dart +++ b/test/servicios/servicio_audio_session_test.dart @@ -33,6 +33,13 @@ class _ObjetivoFake implements ObjetivoAudioInterrumpible { Future setAtenuado(bool atenuado) async { atenuaciones.add(atenuado); } + + int reaplicaciones = 0; + + @override + Future reaplicarEcualizador() async { + reaplicaciones++; + } } /// S3-R1: audio-session interruptions (phone call, transient loss, duck) and From 4168dc50192eca536ccb7e24c1ccea9356590f62 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 01:05:46 +0200 Subject: [PATCH 6/6] fix(alarmas): show which days a weekday alarm actually fires on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alarms list showed a generic "Días" label for a diasSemana alarm instead of its actual configured days. Render the real recurrence (e.g. "Lun, Mié, Vie") by reusing the SAME per-day abbreviation the editor's own day-picker circles already use -- no new formatting scheme, no new ARB keys for the days themselves. Also surface fade/volume/vacation-pause state on the card, each only when it is a genuinely useful deviation from the common case: a fade badge when fadeInSegundos > 0 (reusing the existing alarmFadeInLabel key), a volume percentage when it differs from the 85% default, and a vacation-paused badge when the alarm is both configured to pause and a vacation range is currently active (mirrors the exact predicate ServicioProgramacionAlarmas already uses). One compact line, not a badge per field. Fixes a text-collision regression in pantalla_alarmas_editor_test.dart: opening the editor for an alarm whose own day now renders on its card (e.g. "Lun") made a bare find.text(weekday) ambiguous against the editor's day-picker circle with the same label -- scoped that finder to the BottomSheet subtree. --- lib/l10n/app_ar.arb | 3 +- lib/l10n/app_bn.arb | 3 +- lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 3 +- lib/l10n/app_es.arb | 3 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_hi.arb | 3 +- lib/l10n/app_id.arb | 3 +- lib/l10n/app_it.arb | 3 +- lib/l10n/app_ja.arb | 3 +- lib/l10n/app_pt.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/l10n/gen/app_localizations.dart | 6 + lib/l10n/gen/app_localizations_ar.dart | 3 + lib/l10n/gen/app_localizations_bn.dart | 3 + lib/l10n/gen/app_localizations_de.dart | 3 + lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_es.dart | 3 + lib/l10n/gen/app_localizations_fr.dart | 3 + lib/l10n/gen/app_localizations_hi.dart | 3 + lib/l10n/gen/app_localizations_id.dart | 3 + lib/l10n/gen/app_localizations_it.dart | 3 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_pt.dart | 3 + lib/l10n/gen/app_localizations_ru.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 3 + lib/pantallas/pantalla_alarmas.dart | 99 +++++- .../pantalla_alarmas_editor_test.dart | 21 +- .../pantalla_alarmas_recurrencia_test.dart | 330 ++++++++++++++++++ 30 files changed, 504 insertions(+), 30 deletions(-) create mode 100644 test/pantallas/pantalla_alarmas_recurrencia_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index deb9d1b..489551b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index cf70e01..22fe5c8 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "ছুটির কারণে বিরত" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 41067df..5271aba 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "Wegen Urlaub pausiert" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8b80932..7145eed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "Paused for vacation" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0ff53ee..797266f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -799,5 +799,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "Pausada por vacaciones" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 95e56dd..a2283ae 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "En pause pour les vacances" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index f770523..d48538f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 54de15f..fc50158 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "Dijeda karena liburan" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index b9196bd..a909edd 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "In pausa per le vacanze" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 5bab00d..4ff04d4 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "休暇のため一時停止中" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 11d474a..30f46db 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "Pausada por férias" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 701f3cb..139126b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "Приостановлено на время отпуска" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5fe58a1..8df840b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -840,5 +840,6 @@ "type": "String" } } - } + }, + "alarmCardVacationPausedBadge": "因假期已暂停" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index fca451f..7dd7eb2 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3067,6 +3067,12 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Preset: {preset}'** String eqCustomActionPresetLabel(String preset); + + /// No description provided for @alarmCardVacationPausedBadge. + /// + /// In es, this message translates to: + /// **'Pausada por vacaciones'** + String get alarmCardVacationPausedBadge; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 651c617..0b634e7 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1687,4 +1687,7 @@ class AppLocalizationsAr extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'الإعداد المسبق: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 7474095..6a78fc9 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1696,4 +1696,7 @@ class AppLocalizationsBn extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'প্রিসেট: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 295812a..65b7f97 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1709,4 +1709,7 @@ class AppLocalizationsDe extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Voreinstellung: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index f02de76..5715fcd 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1689,4 +1689,7 @@ class AppLocalizationsEn extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Preset: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'Paused for vacation'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index fbb6307..48a506b 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1703,4 +1703,7 @@ class AppLocalizationsEs extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Preset: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'Pausada por vacaciones'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index f071a13..3982bb0 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1712,4 +1712,7 @@ class AppLocalizationsFr extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Préréglage : $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'En pause pour les vacances'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 6f62753..0f580ab 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1691,4 +1691,7 @@ class AppLocalizationsHi extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'प्रीसेट: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 2b62c6a..c32e472 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1699,4 +1699,7 @@ class AppLocalizationsId extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Prasetel: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'Dijeda karena liburan'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 68bc853..72ff0ef 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1711,4 +1711,7 @@ class AppLocalizationsIt extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Preset attivo: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'In pausa per le vacanze'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index bec733e..b5cad51 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1642,4 +1642,7 @@ class AppLocalizationsJa extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'プリセット: $preset'; } + + @override + String get alarmCardVacationPausedBadge => '休暇のため一時停止中'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index fa10bc0..e53fd7d 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1699,4 +1699,7 @@ class AppLocalizationsPt extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Predefinição: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'Pausada por férias'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 7b6b837..480f9f6 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1703,4 +1703,7 @@ class AppLocalizationsRu extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return 'Пресет: $preset'; } + + @override + String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 6c97bc2..a2a226b 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1634,4 +1634,7 @@ class AppLocalizationsZh extends AppLocalizations { String eqCustomActionPresetLabel(String preset) { return '预设:$preset'; } + + @override + String get alarmCardVacationPausedBadge => '因假期已暂停'; } diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 593b518..cb3b60c 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -265,6 +265,26 @@ class _TarjetaAlarma extends StatelessWidget { ? l10n.noStationUseInternalSound : localizedStationName(l10n, alarma.emisora!.nombre); + // Item 5: surfaces the genuinely useful fields that already exist on + // the model, WITHOUT turning the row into clutter -- each is shown + // only when it is a meaningful deviation from the common case. + // Mirrors EXACTLY the pause predicate `impactoDeRango`/ + // `ServicioProgramacionAlarmas` already use + // (`!sonarEnVacaciones` while `activa`), gated by whether a vacation + // range is CURRENTLY active -- an alarm configured to pause but with + // no active range right now is not actually paused by anything yet. + final pausadaPorVacaciones = + alarma.activa && + !alarma.sonarEnVacaciones && + estado.rangoVacacionesActivo() != null; + final detalles = [ + if (alarma.fadeInSegundos > 0) + l10n.alarmFadeInLabel(alarma.fadeInSegundos), + if ((alarma.volumen * 100).round() != 85) + '${(alarma.volumen * 100).round()}%', + if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge, + ]; + return Dismissible( key: ValueKey('tarjeta-alarma-${alarma.id}'), direction: DismissDirection.horizontal, @@ -309,14 +329,21 @@ class _TarjetaAlarma extends StatelessWidget { ), ), const SizedBox(width: 8), - Text( - _recurrenciaCorta(l10n, alarma), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + // Item 5: real day list can run longer than the + // old generic "Días" label -- Flexible+ellipsis + // keeps a long selection from overflowing the + // Row instead of clipping visibly. + Flexible( + child: Text( + _recurrenciaCorta(l10n, alarma), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme + .onSurface + .withValues(alpha: 0.5), + ), ), ), ], @@ -369,6 +396,25 @@ class _TarjetaAlarma extends StatelessWidget { ), ], ), + // Item 5: fade/volume/vacation-pause state, only + // when each is a genuinely useful deviation from + // the common case (see `detalles` above) -- a + // single compact line, not a badge per field. + if (detalles.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + detalles.join(' · '), + key: ValueKey('tarjeta-alarma-detalles-${alarma.id}'), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.55), + ), + ), + ], ], ), ), @@ -1668,16 +1714,39 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) { String _fechaCorta(AppLocalizations l10n, DateTime fecha) => fechaCortaLocalizada(l10n.localeName, fecha); -/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's -/// giant time. Reuses the SAME generic labels the editor's own -/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption` -/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more -/// specific ARB string -- honest given the space (12px, next to a 34px -/// time) genuinely only fits a short word, not a full weekday list. +/// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the +/// alarm card's giant time. `diaria`/`unica` still show the SAME generic +/// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton` +/// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully +/// specific (there is nothing more concrete to say than "every day"/"just +/// once"). `diasSemana` now renders the alarm's ACTUAL configured days +/// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"), +/// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own +/// day-picker circles already use) -- no new ARB keys, no second +/// formatting scheme, and the resulting Text is wrapped in a +/// `Flexible`+ellipsis at the call site so a long selection never +/// overflows the row. String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) { return switch (alarma.tipoProgramacion) { TipoProgramacionAlarma.diaria => l10n.dailyOption, - TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption, + TipoProgramacionAlarma.diasSemana => _diasSemanaCorto( + l10n, + alarma.diasSemana, + ), TipoProgramacionAlarma.unica => l10n.oneTimeOption, }; } + +/// The real, ordered day abbreviations for a `diasSemana` alarm (item 5), +/// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor +/// always persists it sorted, but this does not rely on that). Falls back +/// to the generic [AppLocalizations.weekdaysOption] label when +/// [diasSemana] is empty -- the editor already blocks saving an empty +/// selection in this mode, but a corrupt/legacy persisted record could +/// still reach here, and showing nothing would be worse than the old +/// generic label. +String _diasSemanaCorto(AppLocalizations l10n, List diasSemana) { + if (diasSemana.isEmpty) return l10n.weekdaysOption; + final ordenados = [...diasSemana]..sort(); + return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', '); +} diff --git a/test/pantallas/pantalla_alarmas_editor_test.dart b/test/pantallas/pantalla_alarmas_editor_test.dart index fbfefd7..1879afa 100644 --- a/test/pantallas/pantalla_alarmas_editor_test.dart +++ b/test/pantallas/pantalla_alarmas_editor_test.dart @@ -190,9 +190,26 @@ void main() { expect(antes, isNot(l10n.alarmNoNextExecution)); // Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy. - await tester.tap(find.text(l10n.weekdayShortTuesday)); + // + // Item 5: the alarm CARD underneath now also renders the real day + // abbreviation ("Lun") for a diasSemana alarm, so a bare + // `find.text(...)` for a weekday letter is ambiguous while the + // editor sheet is open on top of the list — scope to the sheet's own + // BottomSheet subtree to target the day-picker circle specifically. + final hojaEditor = find.byType(BottomSheet); + await tester.tap( + find.descendant( + of: hojaEditor, + matching: find.text(l10n.weekdayShortTuesday), + ), + ); await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.weekdayShortMonday)); + await tester.tap( + find.descendant( + of: hojaEditor, + matching: find.text(l10n.weekdayShortMonday), + ), + ); await tester.pumpAndSettle(); final despues = _textoPreview(tester); diff --git a/test/pantallas/pantalla_alarmas_recurrencia_test.dart b/test/pantallas/pantalla_alarmas_recurrencia_test.dart new file mode 100644 index 0000000..171072d --- /dev/null +++ b/test/pantallas/pantalla_alarmas_recurrencia_test.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Item 5: the alarm list must show which days a `diasSemana` alarm +/// actually fires on (e.g. "Lun, Mié, Vie"), not the generic "Días" label, +/// plus surface fade/volume/vacation-pause state when they are genuinely +/// informative -- without cluttering the row. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future<(EstadoRadio, EstadoAlarmas)> montar( + WidgetTester tester, { + required AlarmaMusical alarma, + List vacaciones = const [], + }) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final radio = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + + await estadoAlarmas.guardarAlarma(alarma); + if (vacaciones.isNotEmpty) { + await estadoAlarmas.guardarVacaciones(vacaciones); + } + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaAlarmas()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + return (radio, estadoAlarmas); + } + + testWidgets( + 'diasSemana alarm shows the ACTUAL configured days (Lun, Mié, Vie), ' + 'not the generic "Días" label', + (tester) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-dias', + nombre: 'Entre semana', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diasSemana, + diasSemana: [DateTime.monday, DateTime.wednesday, DateTime.friday], + ), + ); + + expect(find.text('Lun, Mié, Vie'), findsOneWidget); + expect(find.text('Días'), findsNothing); + }, + ); + + testWidgets('daily alarm still shows "Diaria" (unaffected)', ( + tester, + ) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-diaria', + nombre: 'Todos los días', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(find.text('Diaria'), findsOneWidget); + }); + + testWidgets('one-time alarm still shows "Una vez" (unaffected)', ( + tester, + ) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-unica', + nombre: 'Una sola vez', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.unica, + diasSemana: [], + fechaUnica: null, + ), + ); + + expect(find.text('Una vez'), findsOneWidget); + }); + + testWidgets( + 'a diasSemana alarm with an (invalid/legacy) empty diasSemana falls ' + 'back to the generic label instead of showing nothing', + (tester) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-dias-vacio', + nombre: 'Corrupta', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diasSemana, + diasSemana: [], + ), + ); + + expect(find.text('Días'), findsOneWidget); + }, + ); + + testWidgets('a configured fade-in shows a compact "Fade-in Ns" detail', ( + tester, + ) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-fade', + nombre: 'Con fade', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + fadeInSegundos: 8, + ), + ); + + expect(find.textContaining('Fade-in 8s'), findsOneWidget); + }); + + testWidgets('no fade-in (0s, the default) shows no fade detail', ( + tester, + ) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-sin-fade', + nombre: 'Sin fade', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + fadeInSegundos: 0, + ), + ); + + expect(find.textContaining('Fade-in'), findsNothing); + }); + + testWidgets( + 'a non-default volume shows a compact percentage detail', + (tester) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-vol', + nombre: 'Volumen bajo', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + volumen: 0.5, + ), + ); + + expect(find.textContaining('50%'), findsOneWidget); + }, + ); + + testWidgets('the default volume (85%) shows no volume detail', ( + tester, + ) async { + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-vol-default', + nombre: 'Volumen default', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(find.textContaining('85%'), findsNothing); + }); + + testWidgets( + 'an alarm paused by a CURRENTLY active vacation range shows a ' + 'vacation-paused detail', + (tester) async { + final l10n = lookupAppLocalizations(const Locale('es')); + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-vacaciones', + nombre: 'Pausada', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: false, + ), + vacaciones: [ + // Wide, real-wall-clock-safe range (rangoVacacionesActivo() + // defaults to the REAL DateTime.now(), not this file's injected + // `reloj`) -- deliberately spans many years so the test stays + // valid regardless of exactly when it runs. + RangoVacaciones( + id: 'v1', + nombre: 'Verano', + inicio: DateTime(2020, 1, 1), + fin: DateTime(2030, 12, 31), + ), + ], + ); + + expect( + find.textContaining(l10n.alarmCardVacationPausedBadge), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'an alarm that DOES sound during vacations shows NO vacation-paused ' + 'detail even with an active range', + (tester) async { + final l10n = lookupAppLocalizations(const Locale('es')); + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-suena-vacaciones', + nombre: 'Suena igual', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: true, + ), + vacaciones: [ + // Wide, real-wall-clock-safe range (rangoVacacionesActivo() + // defaults to the REAL DateTime.now(), not this file's injected + // `reloj`) -- deliberately spans many years so the test stays + // valid regardless of exactly when it runs. + RangoVacaciones( + id: 'v1', + nombre: 'Verano', + inicio: DateTime(2020, 1, 1), + fin: DateTime(2030, 12, 31), + ), + ], + ); + + expect( + find.textContaining(l10n.alarmCardVacationPausedBadge), + findsNothing, + ); + }, + ); + + testWidgets( + 'sonarEnVacaciones:false with NO currently-active vacation range shows ' + 'no vacation-paused detail (nothing to be paused BY right now)', + (tester) async { + final l10n = lookupAppLocalizations(const Locale('es')); + await montar( + tester, + alarma: const AlarmaMusical( + id: 'a-sin-rango-activo', + nombre: 'Sin vacaciones activas', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + sonarEnVacaciones: false, + ), + ); + + expect( + find.textContaining(l10n.alarmCardVacationPausedBadge), + findsNothing, + ); + }, + ); +}