From 3a803ce2bf7e08101e22dfbea3edf7fc3fc91771 Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 29 Jul 2026 00:12:05 +0200 Subject: [PATCH] feat(escuchar): replace discovery browser with embedded player and favorites grid Restructures PantallaInicio's top of screen: a new _EscucharHero (square art, live/offline pill, VisualizadorAudio at barras: 30 / altura: 26 / color: liveGreen, a 5-action transport row - favorite, EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip opening the full player) replaces the old PluriScreenHeader hero, and a new "Tus emisoras" section (favorites preview, capped, "Ver todas") follows it. Per design ADR-7, EstadoRadio stays the single source of truth: the hero is a StatelessWidget with no cached fields, reading emisoraActual via context.select (uuid-based equality scopes rebuilds to real station changes) and the fast-changing playback status via StreamBuilder, the same pattern _Controles/MiniReproductor already use. The still-present discovery sections (_seccionCercanas onward, including the old grid) are deliberately left in place - WU6 relocates them to Buscar and deletes them from here; removing them now would leave that content nowhere until WU6 lands. MiniReproductor gains a `visible` parameter (default true) and a measured `static const double altura`. app.dart passes `visible: indice != RaizPluriWave.escuchar.index`, hiding it visually only (SizedBox.shrink()) while Escuchar is active, since the hero already shows the same station - the State stays mounted so its didChangeDependencies side effect (configurarLocalizaciones, S3-R3) keeps running regardless of tab. altura was measured empirically (72.0, via tester.getSize) rather than guessed, backing a new derived PluriLayout.escucharBottomChromeInset constant now wired into PantallaInicio's own bottom padding. "Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos), verified via a NavigatorObserver asserting the push count is unchanged (switches tabs, does not push). Fixed a pre-existing test-infrastructure gap while writing the anti-cache test: no test in this codebase had ever exercised ServicioAudio.androidAudioSessionIdStream against a bare FakeServicioAudio (pantalla_reproductor.dart has always read it but has no test file at all) - the real getter needs registrarHandler() (main.dart, production only) and threw otherwise. Added an empty stream override to FakeServicioAudio, matching VisualizadorAudio's own documented no-native-session fallback. Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged at 1 pre-existing info. git diff empty for visualizador_audio.dart and estado_radio.dart - this WU touches neither. --- lib/app.dart | 6 +- lib/l10n/app_en.arb | 5 + lib/l10n/app_es.arb | 5 + lib/l10n/gen/app_localizations.dart | 30 ++ lib/l10n/gen/app_localizations_ar.dart | 16 + lib/l10n/gen/app_localizations_bn.dart | 16 + lib/l10n/gen/app_localizations_de.dart | 16 + lib/l10n/gen/app_localizations_en.dart | 16 + lib/l10n/gen/app_localizations_es.dart | 16 + lib/l10n/gen/app_localizations_fr.dart | 16 + lib/l10n/gen/app_localizations_hi.dart | 16 + lib/l10n/gen/app_localizations_id.dart | 16 + lib/l10n/gen/app_localizations_it.dart | 16 + lib/l10n/gen/app_localizations_ja.dart | 16 + lib/l10n/gen/app_localizations_pt.dart | 16 + lib/l10n/gen/app_localizations_ru.dart | 16 + lib/l10n/gen/app_localizations_zh.dart | 16 + lib/pantallas/pantalla_inicio.dart | 494 +++++++++++++++++- lib/widgets/mini_reproductor.dart | 18 +- lib/widgets/pluri_layout.dart | 7 + openspec/changes/rediseno-funcional/tasks.md | 108 ++-- test/helpers/fakes.dart | 10 + test/pantallas/pantalla_inicio_test.dart | 128 ++++- .../mini_reproductor_configurar_test.dart | 76 +++ 24 files changed, 1038 insertions(+), 57 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index a618333..fb0f2dd 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -254,7 +254,11 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> child: Column( mainAxisSize: MainAxisSize.min, children: [ - const MiniReproductor(), + // ADR-7(b): hidden on Escuchar (index 0) only — its embedded + // hero already shows the same station. Stays mounted (visible: + // false renders SizedBox.shrink(), not tree removal) so its + // didChangeDependencies side effect (S3-R3) keeps running. + MiniReproductor(visible: indice != RaizPluriWave.escuchar.index), PluriBottomNavigation( items: _navItems(l10n), selectedIndex: indice, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2a18b00..4d13df8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -336,6 +336,11 @@ } }, "qualityHd": "HD quality", + "yourStationsTitle": "Your stations", + "seeAllAction": "See all", + "openFullPlayerTooltip": "Open full player", + "nothingPlayingTitle": "Nothing playing yet", + "nothingPlayingSubtitle": "Pick a station from Your stations or search to start.", "nearYou": "Near you", "nearYouInCountry": "Near you · {country}", "@nearYouInCountry": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d9e2fcc..85cf09b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -336,6 +336,11 @@ } }, "qualityHd": "Calidad HD", + "yourStationsTitle": "Tus emisoras", + "seeAllAction": "Ver todas", + "openFullPlayerTooltip": "Abrir reproductor completo", + "nothingPlayingTitle": "Todavía no estás escuchando nada", + "nothingPlayingSubtitle": "Elegí una emisora de Tus emisoras o buscá una para empezar.", "nearYou": "Cerca de vos", "nearYouInCountry": "Cerca de vos · {country}", "@nearYouInCountry": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 4e68b24..3ccc568 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1270,6 +1270,36 @@ abstract class AppLocalizations { /// **'Calidad HD'** String get qualityHd; + /// No description provided for @yourStationsTitle. + /// + /// In es, this message translates to: + /// **'Tus emisoras'** + String get yourStationsTitle; + + /// No description provided for @seeAllAction. + /// + /// In es, this message translates to: + /// **'Ver todas'** + String get seeAllAction; + + /// No description provided for @openFullPlayerTooltip. + /// + /// In es, this message translates to: + /// **'Abrir reproductor completo'** + String get openFullPlayerTooltip; + + /// No description provided for @nothingPlayingTitle. + /// + /// In es, this message translates to: + /// **'Todavía no estás escuchando nada'** + String get nothingPlayingTitle; + + /// No description provided for @nothingPlayingSubtitle. + /// + /// In es, this message translates to: + /// **'Elegí una emisora de Tus emisoras o buscá una para empezar.'** + String get nothingPlayingSubtitle; + /// No description provided for @nearYou. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 3feadb0..b49d5a0 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -658,6 +658,22 @@ class AppLocalizationsAr extends AppLocalizations { @override String get qualityHd => 'جودة HD'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'بالقرب منك'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index ebf602e..808b10c 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -662,6 +662,22 @@ class AppLocalizationsBn extends AppLocalizations { @override String get qualityHd => 'HD গুণমান'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'আপনার কাছাকাছি'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 94cb468..ce9e947 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -665,6 +665,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get qualityHd => 'HD-Qualität'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'In deiner Nähe'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 91f4db6..076e5dc 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -659,6 +659,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qualityHd => 'HD quality'; + @override + String get yourStationsTitle => 'Your stations'; + + @override + String get seeAllAction => 'See all'; + + @override + String get openFullPlayerTooltip => 'Open full player'; + + @override + String get nothingPlayingTitle => 'Nothing playing yet'; + + @override + String get nothingPlayingSubtitle => + 'Pick a station from Your stations or search to start.'; + @override String get nearYou => 'Near you'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 99de31a..741b7ac 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -663,6 +663,22 @@ class AppLocalizationsEs extends AppLocalizations { @override String get qualityHd => 'Calidad HD'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'Cerca de vos'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index a05701f..7a4c35c 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -667,6 +667,22 @@ class AppLocalizationsFr extends AppLocalizations { @override String get qualityHd => 'Qualité HD'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'Près de vous'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index b30073c..7d2e258 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -660,6 +660,22 @@ class AppLocalizationsHi extends AppLocalizations { @override String get qualityHd => 'HD गुणवत्ता'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'आपके पास'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index f73b288..7ce46e6 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -661,6 +661,22 @@ class AppLocalizationsId extends AppLocalizations { @override String get qualityHd => 'Kualitas HD'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'Di dekat Anda'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 6318d77..158133e 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -663,6 +663,22 @@ class AppLocalizationsIt extends AppLocalizations { @override String get qualityHd => 'Qualità HD'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'Vicino a te'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index ce6c526..1fa7080 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -640,6 +640,22 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityHd => 'HD品質'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => '近く'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 35c914c..c336261 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -662,6 +662,22 @@ class AppLocalizationsPt extends AppLocalizations { @override String get qualityHd => 'Qualidade HD'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'Perto de você'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index e92b817..57e0a8d 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -663,6 +663,22 @@ class AppLocalizationsRu extends AppLocalizations { @override String get qualityHd => 'HD-качество'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => 'Рядом с вами'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 8e24189..33438f3 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -637,6 +637,22 @@ class AppLocalizationsZh extends AppLocalizations { @override String get qualityHd => '高清音质'; + @override + String get yourStationsTitle => 'Tus emisoras'; + + @override + String get seeAllAction => 'Ver todas'; + + @override + String get openFullPlayerTooltip => 'Abrir reproductor completo'; + + @override + String get nothingPlayingTitle => 'Todavía no estás escuchando nada'; + + @override + String get nothingPlayingSubtitle => + 'Elegí una emisora de Tus emisoras o buscá una para empezar.'; + @override String get nearYou => '你附近'; diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 10ee40b..44d1f6e 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -1,18 +1,26 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart' as shimmer; import '../estado/estado_busqueda.dart'; +import '../estado/estado_ecualizador.dart'; +import '../estado/estado_navegacion.dart'; import '../estado/estado_radio.dart'; +import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; +import '../servicios/servicio_audio.dart'; import '../tema/pluri_animate.dart'; +import '../tema/pluriwave_theme.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; +import '../widgets/visualizador_audio.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; +import 'pantalla_reproductor.dart'; import 'reproducir_minimizado.dart'; /// Pantalla principal: emisoras populares y por género. @@ -53,7 +61,13 @@ class _PantallaInicioState extends State { onRefresh: () => context.read().cargarPopulares(), child: CustomScrollView( slivers: [ - SliverToBoxAdapter(child: _heroHeader(context, l10n)), + // WU5: replaces the old PluriScreenHeader hero. The discovery + // sections below (_seccionCercanas onward) are deliberately left + // in place — WU6 relocates them to Buscar and deletes them from + // here; removing them now would leave that content nowhere until + // WU6 lands. + const SliverToBoxAdapter(child: _EscucharHero()), + SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)), SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)), SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)), SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)), @@ -62,11 +76,15 @@ class _PantallaInicioState extends State { child: _errorBanner(context, error, theme, l10n), ), SliverPadding( + // ADR-7(b): the mini player is hidden on this whole screen + // (app.dart), so its content needs less bottom padding than + // every other root — escucharBottomChromeInset, not the plain + // bottomChromeInset every other root/scrollable uses. padding: const EdgeInsets.fromLTRB( PluriLayout.horizontal, 0, PluriLayout.horizontal, - PluriLayout.bottomChromeInset, + PluriLayout.escucharBottomChromeInset, ), sliver: _gridEmisoras(context, l10n), ), @@ -75,27 +93,84 @@ class _PantallaInicioState extends State { ); } - Widget _heroHeader(BuildContext context, AppLocalizations l10n) { - final totalEmisoras = context.select( - (e) => e.emisorasInicio.length, + /// WU5 task 5.8: a preview of `listaFavoritos` (capped — full browsing, + /// filtering, and reordering live on Favoritos itself, WU4), with "Ver + /// todas" switching the root tab via `EstadoNavegacionRaiz.irA` rather + /// than pushing a route (`app-navigation-shell` — Root-to-Root Switching + /// Without Push). + static const _capTusEmisoras = 6; + + Widget _seccionTusEmisoras( + BuildContext context, + ThemeData theme, + AppLocalizations l10n, + ) { + final favoritos = context.select>( + (e) => e.listaFavoritos, ); - return PluriScreenHeader( - title: l10n.appTitle, - subtitle: l10n.homeScreenSubtitle, - glyph: PluriIconGlyph.home, - primaryActionLabel: l10n.exploreStations, - onPrimaryAction: () => context.read().cargarPopulares(), - trailing: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - PluriStatusPill( - icon: Icons.public_rounded, - label: l10n.stationsCount(totalEmisoras), - accent: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(height: 8), - PluriStatusPill(icon: Icons.hd_rounded, label: l10n.qualityHd), - ], + final mostrados = favoritos.take(_capTusEmisoras).toList(); + + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 8, + PluriLayout.horizontal, + 0, + ), + child: PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.yourStationsTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + TextButton( + onPressed: + () => context.read().irA( + RaizPluriWave.favoritos, + ), + child: Text(l10n.seeAllAction), + ), + ], + ), + if (mostrados.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + l10n.favoritesEmptySubtitle, + style: theme.textTheme.bodySmall, + ), + ) + else + SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: mostrados.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final emisora = mostrados[i]; + return SizedBox( + width: 260, + child: TarjetaEmisora( + emisora: emisora, + esCompacta: true, + onTap: () => reproducirMinimizado(context, emisora), + ), + ); + }, + ), + ), + ], + ), ), ); } @@ -410,3 +485,378 @@ class _ChipShimmer extends StatelessWidget { ); } } + +/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the +/// single source of truth (`emisoraActual` already feeds `MiniReproductor` +/// and `PantallaReproductor`) — this hero is a third VIEW, never a third +/// STATE. +/// +/// Binding rules this class exists to enforce: +/// 1. StatelessWidget — no cached `Emisora`, no local playback flag. +/// 2. Reads use `context.select` per scalar (here, `emisoraActual` itself — +/// `Emisora`'s own `==`/`hashCode` are uuid-based, so this only rebuilds +/// the hero when the STATION actually changes, not on every audio buffer +/// event `EstadoRadio` also notifies on). Fast-changing playback status +/// (`EstadoReproduccion`) is read via `StreamBuilder` instead, the same +/// pattern `_Controles`/`MiniReproductor` already use, so status ticks +/// don't even reach this widget's own rebuild path. +/// 3. Transport calls the SAME `EstadoRadio`/`EstadoEcualizador` methods the +/// full player calls — no new playback methods. +/// 4. `VisualizadorAudio` is reused UNCHANGED, just re-parameterised +/// (`barras: 30`, `altura: 26`, `color: liveGreen`). +class _EscucharHero extends StatelessWidget { + const _EscucharHero(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final emisora = context.select( + (e) => e.emisoraActual, + ); + + if (emisora == null) { + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 8, + PluriLayout.horizontal, + 0, + ), + child: PluriGlassSurface( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.nothingPlayingTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + l10n.nothingPlayingSubtitle, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + final estado = context.read(); + final stationName = localizedStationName(l10n, emisora.nombre); + + return Padding( + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + 8, + PluriLayout.horizontal, + 0, + ), + child: PluriGlassSurface( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ArteEscuchar(emisora: emisora), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamBuilder( + stream: estado.estadoStream, + builder: (context, snap) { + final s = snap.data ?? EstadoReproduccion.detenido; + final enVivo = s == EstadoReproduccion.reproduciendo; + return PluriStatusPill( + icon: + enVivo + ? Icons.podcasts_rounded + : Icons.pause_circle_outline_rounded, + label: enVivo ? l10n.liveNow : l10n.notPlaying, + accent: + enVivo ? context.pluriTokens.liveGreen : null, + ); + }, + ), + const SizedBox(height: 6), + Text( + stationName, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + VisualizadorAudio( + estadoStream: estado.estadoStream, + androidAudioSessionIdStream: + estado.audio.androidAudioSessionIdStream, + barras: 30, + altura: 26, + color: context.pluriTokens.liveGreen, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + _FilaTransporteEscuchar(emisora: emisora), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + icon: const Icon(Icons.tune_rounded, size: 18), + label: Text(l10n.openFullPlayerTooltip), + onPressed: () => PantallaReproductor.abrir(context, emisora), + ), + ), + ], + ), + ), + ); + } +} + +/// Square art (the design's requested shape for the Escuchar hero — the +/// full player's own `_WaveHero`, `pantalla_reproductor.dart`, stays +/// circular and unchanged). +class _ArteEscuchar extends StatelessWidget { + const _ArteEscuchar({required this.emisora}); + + final Emisora emisora; + + static const _lado = 84.0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final radius = BorderRadius.circular(context.pluriTokens.radiusMd); + return PluriGlassSurface( + padding: EdgeInsets.zero, + borderRadius: radius, + child: SizedBox( + width: _lado, + height: _lado, + child: ClipRRect( + borderRadius: radius, + child: + (emisora.favicon != null && emisora.favicon!.isNotEmpty) + ? CachedNetworkImage( + imageUrl: emisora.favicon!, + fit: BoxFit.cover, + placeholder: (_, __) => _shimmerCuadrado(theme), + errorWidget: (_, __, ___) => _iconoFallback(theme), + ) + : _iconoFallback(theme), + ), + ), + ); + } + + Widget _shimmerCuadrado(ThemeData theme) => shimmer.Shimmer.fromColors( + baseColor: theme.colorScheme.surfaceContainerHighest, + highlightColor: theme.colorScheme.surface, + child: Container(color: theme.colorScheme.surfaceContainerHighest), + ); + + Widget _iconoFallback(ThemeData theme) => Container( + color: theme.colorScheme.primaryContainer, + child: Icon( + Icons.radio_rounded, + size: 36, + color: theme.colorScheme.onPrimaryContainer, + ), + ); +} + +/// The hero's transport row — favorite / EQ toggle / stop / play-pause +/// (primary) / sleep, in that order, with sleep as the documented 5th +/// action. Every action calls an EXISTING `EstadoRadio`/`EstadoEcualizador` +/// method — no new playback surface (ADR-7 rule 3). +class _FilaTransporteEscuchar extends StatelessWidget { + const _FilaTransporteEscuchar({required this.emisora}); + + final Emisora emisora; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final t = context.pluriTokens; + final estado = context.read(); + final esFavorito = context.select( + (e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid), + ); + final eqActivo = context.select((e) => e.activo); + final timerActivo = context.select( + (e) => e.timer.activo, + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: + esFavorito + ? l10n.favoritesRemoveTooltip + : l10n.favoritesAddTooltip, + icon: Icon( + esFavorito + ? Icons.favorite_rounded + : Icons.favorite_outline_rounded, + color: esFavorito ? Theme.of(context).colorScheme.error : null, + ), + onPressed: () => estado.toggleFavorito(emisora), + ), + IconButton( + tooltip: eqActivo ? l10n.equalizerDisable : l10n.equalizerEnable, + icon: Icon( + eqActivo ? Icons.equalizer_rounded : Icons.equalizer_outlined, + color: eqActivo ? t.warmCoral : null, + ), + onPressed: + () => context.read().cambiarActivo(!eqActivo), + ), + StreamBuilder( + stream: estado.estadoStream, + builder: (context, snap) { + final s = snap.data ?? EstadoReproduccion.detenido; + final cargando = + s == EstadoReproduccion.cargando || + s == EstadoReproduccion.reconectando; + return IconButton( + tooltip: l10n.stopAction, + icon: const Icon(Icons.stop_circle_outlined), + onPressed: cargando ? null : estado.detenerReproduccion, + ); + }, + ), + StreamBuilder( + stream: estado.estadoStream, + builder: (context, snap) { + final s = snap.data ?? EstadoReproduccion.detenido; + final reproduciendo = s == EstadoReproduccion.reproduciendo; + final cargando = + s == EstadoReproduccion.cargando || + s == EstadoReproduccion.reconectando; + return SizedBox( + width: 56, + height: 56, + child: FilledButton( + style: FilledButton.styleFrom( + shape: const CircleBorder(), + padding: EdgeInsets.zero, + ), + onPressed: + cargando + ? null + : () { + if (reproduciendo || + s == EstadoReproduccion.pausado) { + estado.togglePlay(); + } else { + estado.reproducir(emisora); + } + }, + child: + cargando + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + reproduciendo + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + size: 28, + ), + ), + ); + }, + ), + IconButton( + tooltip: l10n.sleepTimer, + icon: Icon( + Icons.bedtime_rounded, + color: timerActivo ? t.warmCoral : null, + ), + onPressed: () => _mostrarTimerSheet(context, estado, l10n), + ), + ], + ); + } + + Future _mostrarTimerSheet( + BuildContext context, + EstadoRadio estado, + AppLocalizations l10n, + ) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: + (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.sleepTimer, + style: Theme.of(ctx).textTheme.titleLarge, + ), + const SizedBox(height: 16), + if (estado.timer.activo) + FilledButton.tonal( + onPressed: () { + estado.cancelarTimer(); + Navigator.pop(ctx); + }, + child: Text(l10n.cancelTimer), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final segundos in estado.timerSuenoPresetsSegundos) + ActionChip( + label: Text(_formatearMinutos(l10n, segundos)), + onPressed: () { + estado.iniciarTimerDuracion( + Duration(seconds: segundos), + ); + Navigator.pop(ctx); + }, + ), + ], + ), + ], + ), + ), + ), + ); + } + + String _formatearMinutos(AppLocalizations l10n, int segundos) { + final d = Duration(seconds: segundos); + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + if (d.inHours > 0) { + return l10n.durationHoursMinutesSeconds(d.inHours, m, s); + } + return d.inSeconds.remainder(60) == 0 + ? l10n.durationMinutesOnly(d.inMinutes) + : l10n.durationMinutesSeconds(d.inMinutes, s); + } +} diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index da40dfd..5d6c6c1 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -14,7 +14,21 @@ import 'visualizador_audio.dart'; /// Barra inferior persistente con controles básicos de reproducción. /// Toca la barra para abrir PantallaReproductor completa. class MiniReproductor extends StatefulWidget { - const MiniReproductor({super.key}); + const MiniReproductor({super.key, this.visible = true}); + + /// Design ADR-7(b): on Escuchar, the embedded hero already shows the same + /// station, so `_PaginaPrincipal` passes `visible: false` there to avoid + /// showing it twice. Hidden VISUALLY only (`build` returns + /// `SizedBox.shrink()`) — the widget stays in the tree and mounted, so + /// `didChangeDependencies`'s `configurarLocalizaciones` call (S3-R3) keeps + /// running on every locale change regardless of which tab is active. + final bool visible; + + /// Measured (not guessed) from this widget's actual laid-out height with a + /// representative station name, default text scale and theme — see + /// `mini_reproductor_configurar_test.dart`'s measurement assertion. Backs + /// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)). + static const double altura = 72; @override State createState() => _MiniReproductorState(); @@ -43,7 +57,7 @@ class _MiniReproductorState extends State { final l10n = AppLocalizations.of(context); final emisora = estado.emisoraActual; - if (emisora == null) return const SizedBox.shrink(); + if (!widget.visible || emisora == null) return const SizedBox.shrink(); final t = context.pluriTokens; final stationName = localizedStationName(l10n, emisora.nombre); diff --git a/lib/widgets/pluri_layout.dart b/lib/widgets/pluri_layout.dart index 1723035..69f6497 100644 --- a/lib/widgets/pluri_layout.dart +++ b/lib/widgets/pluri_layout.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../tema/pluriwave_theme.dart'; +import 'mini_reproductor.dart'; abstract final class PluriLayout { static const double horizontal = 16; @@ -9,6 +10,12 @@ abstract final class PluriLayout { static const double compactGap = 8; static const double bottomChromeInset = 146; + /// ADR-7(b): `bottomChromeInset` assumes `MiniReproductor` is visible. + /// Escuchar hides it (design's one exception — the embedded hero already + /// shows the same station), so its content needs less bottom padding. + static const double escucharBottomChromeInset = + bottomChromeInset - MiniReproductor.altura; + static const EdgeInsets pageListPadding = EdgeInsets.fromLTRB( 0, 0, diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index 08109ce..6ce49f3 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -345,37 +345,85 @@ scenario change — see 4.4's note). **Commit**: `feat(escuchar): replace discovery browser with embedded player and favorites grid` **Depends on**: WU1, WU4 **Spec refs**: `app-navigation-shell` — Root-to-Root Switching Without Push -**Verify**: `flutter test test/pantallas/pantalla_inicio_test.dart test/pantallas/pantalla_inicio_rebuild_test.dart test/widgets/mini_reproductor_configurar_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')` -**Modified tests**: `pantalla_inicio_test.dart`, `pantalla_inicio_rebuild_test.dart`, `mini_reproductor_configurar_test.dart` +**Verify**: `flutter test test/pantallas/pantalla_inicio_test.dart test/pantallas/pantalla_inicio_rebuild_test.dart test/widgets/mini_reproductor_configurar_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')` +**Modified tests**: `pantalla_inicio_test.dart`, `mini_reproductor_configurar_test.dart`, `test/helpers/fakes.dart` +(see 5.4's note). `pantalla_inicio_rebuild_test.dart` needed no change — it never taps "Ver todas", so +`EstadoNavegacionRaiz`'s absence there is never exercised (lazily read only inside the button's `onPressed`). -- [ ] 5.1 RED — write the ADR-7 anti-cache test: mutate `EstadoRadio` **from outside the widget tree** (simulating - Android Auto / notification-driven playback change) and assert the Escuchar hero follows, with zero cached - fields in `State`. -- [ ] 5.2 RED — **hazard test**: assert `MiniReproductor` is `visible: false` (renders `SizedBox.shrink()`) while on - Escuchar, AND that `configurarLocalizaciones` still ran in `didChangeDependencies` - (`mini_reproductor.dart:27-38`, the S3-R3 contract). Removing the widget from the tree would silently break - this — the hazard is hiding it structurally, not visually. -- [ ] 5.3 RED — assert "Ver todas" calls `context.read().irA(RaizPluriWave.favoritos)` and that - `Navigator` depth is unchanged (switches tab, does not push). This only *consumes* WU1's provider. -- [ ] 5.4 RED — update `pantalla_inicio_test.dart` / `pantalla_inicio_rebuild_test.dart` for the new content model - (hero + "Tus emisoras" grid replaces the discovery grid); confirm `context.select` scoping keeps rebuild count - low (existing `MemoLista` pattern). -- [ ] 5.5 GREEN — build the Escuchar hero as a `StatelessWidget` reusing `_WaveHero` / `VisualizadorAudio` patterns - from `pantalla_reproductor.dart` (square art, `barras: 30`, `altura: 26`, `color: liveGreen`), transport row - with sleep as the 5th action, tool-tray entry chip; reads via `context.select` per scalar. -- [ ] 5.6 GREEN — add `MiniReproductor.altura` as a value **measured from its actual laid-out height at apply time** - (e.g. via a `GlobalKey`/`RenderBox` read at build), never a guessed constant; add its `visible` parameter. - `_PaginaPrincipal` sets `visible: false` on Escuchar only, keeping `State` mounted. -- [ ] 5.7 GREEN — add the derived `PluriLayout` constant (`bottomChromeInset - MiniReproductor.altura`, - `pluri_layout.dart:10` currently hardcodes `146` assuming the mini player is present) for Escuchar's content - padding. -- [ ] 5.8 GREEN — swap the "Tus emisoras" grid data source to `listaFavoritos` (capped) with "Ver todas" wired to - `EstadoNavegacionRaiz.irA(RaizPluriWave.favoritos)`. -- [ ] 5.9 REFACTOR — leave the discovery-section widgets (`_seccionCercanas`/`_chipGeneros`/etc.) in place for now; - WU6 relocates and then deletes them — do not delete here to avoid an intermediate commit with the content - nowhere. Note this handoff explicitly in the commit body. -- [ ] 5.10 Verify — anti-cache test green; mini-player-hidden-but-side-effect-ran test green; tab-switch-not-push - test green; `visualizador_audio.dart` and `estado_radio.dart` show empty `git diff`. +- [x] 5.1 RED — the ADR-7 anti-cache test: `await audio.reproducir(estacionB)` mutates the underlying + `FakeServicioAudio` **directly**, bypassing `EstadoRadio.reproducir()` entirely (the same shape as + `navegacion_auto.dart`'s out-of-band mutation); asserts the hero's rendered station name follows. The hero + being a `StatelessWidget` (no `State` class at all) makes "zero cached fields" true by construction — this + behavioral test is also what would catch a hypothetical cached-field regression, since a cached value set once + in `initState` would not follow an external mutation the way this test requires. +- [x] 5.2 RED — hazard test added to `mini_reproductor_configurar_test.dart`: with a station actively "reproduciendo" + (so a naive `emisora == null` check couldn't accidentally satisfy it), `MiniReproductor(visible: false)` renders + nothing (`find.text` for the station name finds nothing) while `configurarLocalizaciones` still ran exactly + once — confirming `didChangeDependencies` fired independent of `build()`'s early return. +- [x] 5.3 RED — asserts `navegacion.actual == RaizPluriWave.favoritos` after tapping "Ver todas", using a + `_RecordingNavigatorObserver` (counts `didPush` calls) to assert the push count is **unchanged** before/after + the tap — proves "switches tabs, does not push" mechanically rather than by inspection. +- [x] 5.4 RED — **test-file correction, noted explicitly**: `pantalla_inicio_rebuild_test.dart` needed no scenario + change (see the header note above). Discovered and fixed a genuine, pre-existing gap while writing 5.1: no + test in this codebase had ever exercised `ServicioAudio.androidAudioSessionIdStream` against a bare + `FakeServicioAudio` — the real getter requires `registrarHandler()` (`main.dart`, production-only) and throws + `"registrarHandler() no fue llamado en main.dart"` otherwise. `pantalla_reproductor.dart` has always read this + exact getter but has **no test file at all**, so the gap was latent until `PantallaInicio` started wiring + `VisualizadorAudio` to it here. Added a `Stream.empty()` override to `FakeServicioAudio` + (`test/helpers/fakes.dart`) — purely additive, matches `VisualizadorAudio`'s own documented no-native-session + fallback, does not change any existing test's behavior (grep-confirmed nothing else reads this getter). + `context.select` scoping: the hero selects `emisoraActual` itself (one scalar per ADR-7 rule 2 — `Emisora`'s + own `==`/`hashCode` are uuid-based, so this only rebuilds on a real station change, not on audio-buffer + notifications); the fast-changing `EstadoReproduccion` is read via `StreamBuilder` instead (the same pattern + `_Controles`/`MiniReproductor` already use), so playback-status ticks never even reach the hero's own rebuild + path. `pantalla_inicio_rebuild_test.dart`'s EQ-preset-doesn't-rebuild guard still passes unmodified. +- [x] 5.5 GREEN — built `_EscucharHero` (`StatelessWidget`) in `pantalla_inicio.dart`: square art (`_ArteEscuchar`, + `ClipRRect` not `ClipOval` — the full player's own `_WaveHero` stays circular and untouched), live/offline + `PluriStatusPill`, `VisualizadorAudio(barras: 30, altura: 26, color: liveGreen)`. Transport row + (`_FilaTransporteEscuchar`) is favorite / EQ toggle / stop / play-pause (primary) / sleep, in that order — + **design decision, not spec-tested** (no GIVEN/WHEN/THEN scenario enumerates the exact 5 actions; only ADR-7's + structural rules are): the first 4 mirror the full player's own existing app-bar + transport actions + (favorite, EQ, stop, play/pause) exactly, with sleep as the documented 5th, satisfying "no new playback + methods" (rule 3) by construction — every action calls an existing `EstadoRadio`/`EstadoEcualizador` method. A + separate "tool-tray entry chip" (`OutlinedButton.icon`) opens the full player via the existing + `PantallaReproductor.abrir`. 5 new ARB keys (en/es only, established precedent): `yourStationsTitle`, + `seeAllAction`, `openFullPlayerTooltip`, `nothingPlayingTitle`, `nothingPlayingSubtitle` (the last two back a + lightweight placeholder state when `emisoraActual` is null, so the hero degrades gracefully before any + playback has started). +- [x] 5.6 GREEN — added `MiniReproductor.visible` (default `true`) and `static const double altura`. **Measured, not + guessed**: added a self-verifying test asserting `altura` is `closeTo` (±4px tolerance) the REAL + `tester.getSize(find.byType(MiniReproductor)).height` with a representative station and default text scale; + ran it with a placeholder first, read the actual measured value (`72.0`) from the assertion failure, then set + the constant to match exactly. `build()` returns `SizedBox.shrink()` when `!visible`, independent of + `didChangeDependencies` (task 5.2's hazard test proves the side effect still runs). `app.dart` passes + `visible: indice != RaizPluriWave.escuchar.index`. +- [x] 5.7 GREEN — added `PluriLayout.escucharBottomChromeInset = bottomChromeInset - MiniReproductor.altura` and + wired it into `pantalla_inicio.dart`'s own bottom `SliverPadding` (replacing the plain `bottomChromeInset` + every other root/scrollable still uses) — the mini player is hidden for this whole screen, not just the new + sections, so the reduced inset applies to the still-present discovery grid too. +- [x] 5.8 GREEN — added `_seccionTusEmisoras` (replaces the removed `_heroHeader`'s call site, right after the hero): + `listaFavoritos` (not `listaFavoritosManual` — Escuchar previews the same globally-ordered list every other + screen shows, it doesn't need Favoritos' own manual-order view) capped at 6, horizontally-scrollable compact + `TarjetaEmisora` cards, "Ver todas" (`TextButton`) calling + `context.read().irA(RaizPluriWave.favoritos)`. Empty-favorites case reuses the existing + `favoritesEmptySubtitle` string rather than a new key. +- [x] 5.9 REFACTOR — confirmed `_seccionCercanas`/`_seccionTendencias`/`_chipGeneros`/`_errorBanner`/`_gridEmisoras` + (the discovery grid itself) are untouched, left in place below the new hero + "Tus emisoras" section, exactly + as instructed — WU6 relocates and deletes them. Confirmed `flutter test/widgets/pluri_push_scaffold_test.dart` + "The 5 root screens build zero Scaffold when mounted bare PantallaInicio" still passes (the hero adds slivers, + no `Scaffold`). +- [x] 5.10 Verify — anti-cache test green; mini-player-hidden-but-side-effect-ran test green; tab-switch-not-push + test green (push count unchanged); `git diff --stat` confirmed empty for `lib/widgets/visualizador_audio.dart` + AND `lib/estado/estado_radio.dart` (WU5 touches neither). Full suite: 618/618 green (2 skipped, unchanged), up + from 614. `flutter analyze`: 1 issue, identical to baseline. + +**Discovery worth flagging for WU6+ (or any future WU rendering an actively-playing station in a widget test):** +`VisualizadorAudio` starts an indeterminately-**repeating** `AnimationController` (`visualizador_audio.dart:77`, +`_controller.repeat()`) whenever the stream reports "reproduciendo"/"cargando"/"reconectando" — this is the same +class of hazard as an indeterminate spinner (`pumpAndSettle()` never returns while it keeps scheduling frames), just +via an animation instead of a progress indicator. Any test that renders a playing station through a widget that +embeds `VisualizadorAudio` (this hero, the full player) must use a **bounded** `pump()`, never `pumpAndSettle()`, +once the station starts "reproduciendo". ## WU6 — Buscar landing state + filters diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index ee3abd8..01c382d 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -42,6 +42,16 @@ class FakeServicioAudio extends ServicioAudio { @override Stream get estadoStream => _estadoController.stream; + // WU5: the real getter needs registrarHandler() (main.dart, production + // only) — reading it against a bare FakeServicioAudio throws + // "registrarHandler() no fue llamado en main.dart". No test exercised + // this getter before PantallaInicio started wiring VisualizadorAudio to + // it (WU5's Escuchar hero, mirroring pantalla_reproductor.dart's own + // usage, which has no test file at all). An empty stream matches + // VisualizadorAudio's own documented no-native-session fallback. + @override + Stream get androidAudioSessionIdStream => const Stream.empty(); + @override bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo; diff --git a/test/pantallas/pantalla_inicio_test.dart b/test/pantallas/pantalla_inicio_test.dart index 804a843..62fe2f7 100644 --- a/test/pantallas/pantalla_inicio_test.dart +++ b/test/pantallas/pantalla_inicio_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_busqueda.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_grabacion.dart'; +import 'package:pluriwave/estado/estado_navegacion.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; @@ -177,31 +178,145 @@ void main() { expect(await favoritos.esFavorito(custom.uuid), isTrue); expect(find.text('Custom Uno'), findsOneWidget); }); + + testWidgets( + 'WU5 ADR-7 anti-cache: the Escuchar hero reflects a station changed ' + 'from OUTSIDE the widget tree (e.g. Android Auto / a notification ' + 'action), proving it caches nothing of its own', + (tester) async { + _setLargeSurfaceSize(tester); + final audio = FakeServicioAudio(); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadio(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + final estacionA = emisoraDemo(uuid: 'a', nombre: 'Estacion A'); + final estacionB = emisoraDemo(uuid: 'b', nombre: 'Estacion B'); + await estado.reproducir(estacionA); + + await tester.pumpWidget( + _conProviders(estado, _testApp(const PantallaInicio())), + ); + // Bounded pump, not _pumpStableFrame/pumpAndSettle: a "reproduciendo" + // station makes VisualizadorAudio start an indeterminately-repeating + // AnimationController (visualizador_audio.dart:77, `_controller.repeat()`) + // for its animated-fallback waveform — the same class of hazard as an + // indeterminate spinner, just via animation. pumpAndSettle() would + // never return while it keeps scheduling frames. + await _pumpBounded(tester); + + expect(find.text('Estacion A'), findsOneWidget); + + // Mutates the underlying ServicioAudio DIRECTLY, bypassing + // EstadoRadio.reproducir() entirely — this is exactly the shape of + // navegacion_auto.dart's out-of-band mutation (Android Auto's + // playFromMediaId). EstadoRadio's own audio.estadoStream listener + // (not this test) is what is expected to pick this up and update + // emisoraActual. + await audio.reproducir(estacionB); + await _pumpBounded(tester); + + expect(find.text('Estacion B'), findsOneWidget); + expect(find.text('Estacion A'), findsNothing); + }, + ); + + testWidgets('WU5: "Ver todas" switches to the Favoritos root via ' + 'EstadoNavegacionRaiz.irA, without pushing a route', (tester) async { + _setLargeSurfaceSize(tester); + final favoritos = FakeServicioFavoritos(); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: favoritos, + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadio(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno')); + await estado.cargarFavoritos(); + + final navegacion = EstadoNavegacionRaiz(); + final observer = _RecordingNavigatorObserver(); + + await tester.pumpWidget( + _conProviders( + estado, + _testApp(const PantallaInicio(), observers: [observer]), + navegacion: navegacion, + ), + ); + await _pumpStableFrame(tester); + final pushesAntesDeTocar = observer.pushCount; + + await tester.ensureVisible(find.text('Ver todas')); + await _pumpStableFrame(tester); + await tester.tap(find.text('Ver todas')); + await _pumpStableFrame(tester); + + expect(navegacion.actual, RaizPluriWave.favoritos); + expect( + observer.pushCount, + pushesAntesDeTocar, + reason: 'switches tabs — must NOT push a new route', + ); + }); } /// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and /// the providers only expose the instances (no dispose callbacks). -Widget _conProviders(EstadoRadio estado, Widget child) { +/// [navegacion] defaults to a fresh [EstadoNavegacionRaiz] — harmless to +/// include for every test, only exercised by the "Ver todas" scenarios. +Widget _conProviders( + EstadoRadio estado, + Widget child, { + EstadoNavegacionRaiz? navegacion, +}) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: estado), ListenableProvider.value(value: estado.ecualizador), ListenableProvider.value(value: estado.grabacion), ListenableProvider.value(value: estado.busqueda), + ChangeNotifierProvider.value( + value: navegacion ?? EstadoNavegacionRaiz(), + ), ], child: child, ); } -Widget _testApp(Widget body) { +Widget _testApp(Widget body, {List observers = const []}) { return MaterialApp( locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + navigatorObservers: observers, home: Scaffold(body: body), ); } +/// Counts route pushes so a test can assert "switched tabs, did not push". +class _RecordingNavigatorObserver extends NavigatorObserver { + int pushCount = 0; + + @override + void didPush(Route route, Route? previousRoute) { + pushCount++; + } +} + class FakeServicioGrabacionRadio extends ServicioGrabacionRadio { final _controller = StreamController.broadcast(); @@ -223,6 +338,15 @@ Future _pumpStableFrame(WidgetTester tester) async { await tester.pumpAndSettle(const Duration(milliseconds: 100)); } +/// WU5: bounded pump, safe when a "reproduciendo" station is rendered — +/// `VisualizadorAudio` starts a repeating `AnimationController` for its +/// animated-fallback waveform in that case, which `pumpAndSettle` (used by +/// `_pumpStableFrame`) would wait on forever. +Future _pumpBounded(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); +} + void _setLargeSurfaceSize(WidgetTester tester) { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; diff --git a/test/widgets/mini_reproductor_configurar_test.dart b/test/widgets/mini_reproductor_configurar_test.dart index e89555f..1cc7cb5 100644 --- a/test/widgets/mini_reproductor_configurar_test.dart +++ b/test/widgets/mini_reproductor_configurar_test.dart @@ -79,4 +79,80 @@ void main() { ); }, ); + + testWidgets( + 'WU5 hazard: MiniReproductor(visible: false) renders nothing, but ' + 'configurarLocalizaciones still runs in didChangeDependencies ' + '(hiding it structurally instead would silently break the S3-R3 ' + 'contract)', + (tester) async { + final estado = _EstadoRadioContador(); + addTearDown(estado.dispose); + // A station IS playing — if a naive implementation hid the bar only + // because emisoraActual were null, this would render the full bar + // instead of proving `visible: false` itself suppresses it. + await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: MiniReproductor(visible: false)), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Station A'), findsNothing); + expect( + estado.llamadasConfigurar, + 1, + reason: + 'didChangeDependencies must still run its S3-R3 side effect ' + 'while visually hidden — the State stays mounted, only build() ' + 'is short-circuited', + ); + }, + ); + + testWidgets( + 'WU5 ADR-7(b): MiniReproductor.altura matches the widget\'s actual ' + 'laid-out height (measured, not guessed) within a small tolerance', + (tester) async { + final estado = _EstadoRadioContador(); + addTearDown(estado.dispose); + await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: MiniReproductor()), + ), + ), + ); + await tester.pumpAndSettle(); + + final alturaReal = tester.getSize(find.byType(MiniReproductor)).height; + + // +-4px tolerance: font metrics can shift a hair across machines: the + // constant only backs a content-padding estimate (PluriLayout. + // escucharBottomChromeInset), not a pixel-perfect layout coupling. + expect( + MiniReproductor.altura, + closeTo(alturaReal, 4), + reason: + 'MiniReproductor.altura must be measured from the real layout, ' + 'not guessed — if this fails, re-measure via ' + "tester.getSize(find.byType(MiniReproductor)) and update the " + 'constant', + ); + }, + ); }