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.
This commit is contained in:
+5
-1
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 => 'بالقرب منك';
|
||||
|
||||
|
||||
@@ -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 => 'আপনার কাছাকাছি';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'आपके पास';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '近く';
|
||||
|
||||
|
||||
@@ -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ê';
|
||||
|
||||
|
||||
@@ -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 => 'Рядом с вами';
|
||||
|
||||
|
||||
@@ -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 => '你附近';
|
||||
|
||||
|
||||
@@ -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<PantallaInicio> {
|
||||
onRefresh: () => context.read<EstadoRadio>().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<PantallaInicio> {
|
||||
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<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
|
||||
final totalEmisoras = context.select<EstadoRadio, int>(
|
||||
(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<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
);
|
||||
return PluriScreenHeader(
|
||||
title: l10n.appTitle,
|
||||
subtitle: l10n.homeScreenSubtitle,
|
||||
glyph: PluriIconGlyph.home,
|
||||
primaryActionLabel: l10n.exploreStations,
|
||||
onPrimaryAction: () => context.read<EstadoRadio>().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<EstadoNavegacionRaiz>().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<EstadoRadio, Emisora?>(
|
||||
(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<EstadoRadio>();
|
||||
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<EstadoReproduccion>(
|
||||
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<EstadoRadio>();
|
||||
final esFavorito = context.select<EstadoRadio, bool>(
|
||||
(e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid),
|
||||
);
|
||||
final eqActivo = context.select<EstadoEcualizador, bool>((e) => e.activo);
|
||||
final timerActivo = context.select<EstadoRadio, bool>(
|
||||
(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<EstadoEcualizador>().cambiarActivo(!eqActivo),
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
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<EstadoReproduccion>(
|
||||
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<void> _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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MiniReproductor> createState() => _MiniReproductorState();
|
||||
@@ -43,7 +57,7 @@ class _MiniReproductorState extends State<MiniReproductor> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EstadoNavegacionRaiz>().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<EstadoRadio, T>` 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<int?>.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<EstadoNavegacionRaiz>().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
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
@override
|
||||
Stream<EstadoReproduccion> 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<int?> get androidAudioSessionIdStream => const Stream<int?>.empty();
|
||||
|
||||
@override
|
||||
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
|
||||
|
||||
|
||||
@@ -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<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
||||
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
|
||||
value: navegacion ?? EstadoNavegacionRaiz(),
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _testApp(Widget body) {
|
||||
Widget _testApp(Widget body, {List<NavigatorObserver> 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<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
pushCount++;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
|
||||
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
||||
|
||||
@@ -223,6 +338,15 @@ Future<void> _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<void> _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;
|
||||
|
||||
@@ -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<EstadoRadio>.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<EstadoRadio>.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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user