fix(escuchar,reproductor): add the section-heading token and screen 1-3 polish

Audit S7 plus the open items on screens 1-3. Escuchar gains the
prototype's own 52px "now listening" eyebrow header (t4:53), which is
structurally different from the 56px title row every other root uses
(t4:325) -- the earlier wiring test asserted PluriRootHeader on all five
roots, an over-broad premise now corrected to guard what actually holds:
no AppBar, and the sleep-timer action still reachable.
This commit is contained in:
2026-07-30 14:48:06 +02:00
parent 7ff156852f
commit 01615f9751
34 changed files with 1129 additions and 218 deletions
+232 -42
View File
@@ -14,7 +14,6 @@ import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import '../widgets/visualizador_audio.dart';
@@ -38,35 +37,46 @@ class _PantallaInicioState extends State<PantallaInicio> {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
return CustomScrollView(
slivers: [
// S1 (Tier 1 visual fidelity): the prototype has no global AppBar —
// this root now draws its own 56px title row instead of relying on
// app.dart's removed shared chrome (which is also where the
// sleep-timer action used to live).
SliverToBoxAdapter(
child: PluriRootHeader(
title: l10n.navHome,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
),
// WU5 built the hero; WU6 relocated the discovery sections that
// used to follow it (_seccionCercanas, _seccionTendencias,
// _chipGeneros, _errorBanner, the browse grid) into
// PantallaBuscar's landing state and DELETED them here (this WU's
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
// content is now just the hero and the favorites preview below;
// pull-to-refresh was dropped along with the grid it refreshed —
// the retry button that used to live in the (now relocated) error
// banner already covers manual recovery on Buscar.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
// 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.
const SliverToBoxAdapter(
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
// Audit 1.1 (t4 lines 49-51): a full-bleed art/gradient/noise wash sits
// BEHIND the whole screen, not just inside the hero card -- the Stack
// wrapper is new; everything that used to be the bare CustomScrollView
// return value is unchanged below.
return Stack(
fit: StackFit.expand,
children: [
const _FondoEscuchar(),
CustomScrollView(
slivers: [
// Audit 1.2 (t4 line 53): unlike the other 3 roots' plain 56px
// PluriRootHeader, Escuchar's own header is a 52px "Now
// listening" eyebrow row -- the tab bar already identifies this
// destination, so it never repeats "Escuchar" as a title.
SliverToBoxAdapter(
child: _CabeceraEscuchar(
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
),
// WU5 built the hero; WU6 relocated the discovery sections that
// used to follow it (_seccionCercanas, _seccionTendencias,
// _chipGeneros, _errorBanner, the browse grid) into
// PantallaBuscar's landing state and DELETED them here (this WU's
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
// content is now just the hero and the favorites preview below;
// pull-to-refresh was dropped along with the grid it refreshed —
// the retry button that used to live in the (now relocated) error
// banner already covers manual recovery on Buscar.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(
child: _seccionTusEmisoras(context, theme, l10n),
),
// 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.
const SliverToBoxAdapter(
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
),
],
),
],
);
@@ -112,9 +122,10 @@ class _PantallaInicioState extends State<PantallaInicio> {
Flexible(
child: Text(
l10n.yourStationsTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
// S7 (visual fidelity, t4 line 80): the in-page
// section heading style (15/w800/ls-.2), not
// titleMedium(16)/w900.
style: context.pluriType.inPageSectionHeading,
overflow: TextOverflow.ellipsis,
),
),
@@ -187,6 +198,134 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
}
/// Audit 1.1 (t4 lines 49-51): a full-bleed backdrop behind the ENTIRE
/// screen -- 400px of the current station's own art at 50% opacity, a
/// gradient fading it into the page background by 430px, and a faint noise
/// texture. Reads `emisoraActual` ITSELF (not a parent watch), so this stays
/// the only new subscriber -- `_PantallaInicioState.build()` keeps its
/// documented S4-R5 "no root watch" rule. Renders the gradient/noise even
/// with nothing playing (there is simply no image layer to show then) so
/// the page keeps the same base depth in every state.
class _FondoEscuchar extends StatelessWidget {
const _FondoEscuchar();
static const double _altoArte = 400;
static const double _altoDegradado = 430;
@override
Widget build(BuildContext context) {
final emisora = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraActual,
);
final tokens = context.pluriTokens;
final favicon = emisora?.favicon;
return IgnorePointer(
key: const ValueKey('escuchar-background-art'),
child: Stack(
children: [
if (favicon != null && favicon.isNotEmpty)
SizedBox(
width: double.infinity,
height: _altoArte,
child: Opacity(
opacity: 0.5,
child: CachedNetworkImage(
imageUrl: favicon,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.4),
tokens.deepViolet.withValues(alpha: 0.86),
tokens.deepViolet,
],
stops: const [0, 0.58, 1],
),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: Opacity(
opacity: 0.05,
child: Image.asset(
'assets/images/noise_soft.png',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
],
),
);
}
}
/// Audit 1.2 (t4 line 53): Escuchar's own header -- 52px, an eyebrow-styled
/// "Now listening" label (not a screen title -- the tab bar already
/// identifies this destination) and a single trailing action.
///
/// The prototype also draws a `cast` icon here. This app has no casting
/// service anywhere in `lib/servicios` (checked before writing this) --
/// inventing a Chromecast-style button with no backing capability would fail
/// the same "don't invent a capability absent from the domain" test WU5/WU9
/// already established elsewhere in this app, so it is omitted, not faked.
/// The ONE real action this row keeps is the sleep timer, which used to be
/// [PluriRootHeader]'s bedtime button on this exact screen.
class _CabeceraEscuchar extends StatelessWidget {
const _CabeceraEscuchar({required this.onSleepTimer});
final VoidCallback onSleepTimer;
static const double altura = 52;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SizedBox(
height: altura,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 10, 0),
child: Row(
children: [
Expanded(
child: Text(
l10n.nowListeningLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
),
),
IconButton(
icon: const Icon(Icons.bedtime_outlined),
iconSize: 23,
tooltip: l10n.sleepTimer,
onPressed: onSleepTimer,
),
],
),
),
);
}
}
/// 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
@@ -292,8 +431,14 @@ class _EscucharHero extends StatelessWidget {
const SizedBox(height: 6),
Text(
stationName,
// Audit 1.5 (t4 line 60): 24/w800/ls-.5/height 1.12
// -- titleLarge is 22 with neither letter-spacing
// nor an explicit line height.
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -334,12 +479,55 @@ class _EscucharHero extends StatelessWidget {
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),
// Audit 1.9 (t4 line 78): a CENTRED pill -- padding:9px 15px,
// radius:999, rgba(255,255,255,.07) + border .1, 12px/w700,
// icon expand_less 17px -- not a left-aligned OutlinedButton
// with tune_rounded at 18px.
Center(
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => PantallaReproductor.abrir(context, emisora),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 9,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.expand_less_rounded,
size: 17,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
const SizedBox(width: 6),
Text(
l10n.openFullPlayerTooltip,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
),
],
),
),
),
),
),
),
],
@@ -474,8 +662,10 @@ class _FilaTransporteEscuchar extends StatelessWidget {
s == EstadoReproduccion.cargando ||
s == EstadoReproduccion.reconectando;
return SizedBox(
width: 56,
height: 56,
// Audit 1.8 (t4 line 73): 72x72 with a 38px icon, not 56x56
// with a 28px icon.
width: 72,
height: 72,
child: FilledButton(
style: FilledButton.styleFrom(
shape: const CircleBorder(),
@@ -503,7 +693,7 @@ class _FilaTransporteEscuchar extends StatelessWidget {
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 28,
size: 38,
),
),
);