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:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -13,6 +15,7 @@ import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_timer.dart';
|
||||
import '../tema/pluri_animate.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/ecualizador_widget.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
@@ -117,83 +120,110 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||
);
|
||||
},
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_ArteReproductor(
|
||||
emisora: emisoraActiva,
|
||||
estadoStream: estado.estadoStream,
|
||||
).pluriScaleIn(
|
||||
context,
|
||||
begin: 0.86,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
curve: Curves.easeOutBack,
|
||||
// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
|
||||
// station's own art sits BEHIND the scrollable content -- the Stack
|
||||
// wrapper is new; the SafeArea/SingleChildScrollView subtree below is
|
||||
// unchanged.
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_FondoReproductor(emisora: emisoraActiva, tokens: tokens),
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_ArteReproductor(
|
||||
emisora: emisoraActiva,
|
||||
estadoStream: estado.estadoStream,
|
||||
).pluriScaleIn(
|
||||
context,
|
||||
begin: 0.86,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
emisoraActiva.nombre,
|
||||
// Audit 2.3 (t4 line 116): 26/w800/ls-.5/height 1.15 --
|
||||
// headlineSmall is 24/w700 with neither letter-spacing
|
||||
// nor an explicit line height.
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.15,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 150),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_SubtituloInfo(emisora: emisoraActiva).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 190),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Audit 2.10 (t4 lines 120-122): the bars sit directly on
|
||||
// the background -- no wrapping glass card. The key moves
|
||||
// from the (removed) PluriGlassSurface onto the
|
||||
// visualizer itself so existing finders keep working.
|
||||
VisualizadorAudio(
|
||||
key: const Key('player-visualizer'),
|
||||
estadoStream: estado.estadoStream,
|
||||
androidAudioSessionIdStream:
|
||||
estado.audio.androidAudioSessionIdStream,
|
||||
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at
|
||||
// 40px, gradient ending at 45% alpha (not the hero's
|
||||
// 30%).
|
||||
barras: 30,
|
||||
color: tokens.warmCoral,
|
||||
altura: 40,
|
||||
barrasDiscretas: true,
|
||||
gradienteFinAlpha: 0.45,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 270),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
_Controles(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
esFavorito: esFavorito,
|
||||
).pluriFadeSlideIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 310),
|
||||
beginY: 0.3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_BandejaHerramientas(
|
||||
estado: estado,
|
||||
eq: eq,
|
||||
emisora: emisoraActiva,
|
||||
compartir: _compartir,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 350),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Item 26 / audit 2.4 (t4:114-140): the quality row is
|
||||
// LAST, after the tool tray -- it used to sit right after
|
||||
// the subtitle, 4 positions too early.
|
||||
_FilaCalidad(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 390),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
emisoraActiva.nombre,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
|
||||
const SizedBox(height: 6),
|
||||
_SubtituloInfo(
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 190)),
|
||||
const SizedBox(height: 14),
|
||||
PluriGlassSurface(
|
||||
key: const Key('player-visualizer'),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
child: VisualizadorAudio(
|
||||
estadoStream: estado.estadoStream,
|
||||
androidAudioSessionIdStream:
|
||||
estado.audio.androidAudioSessionIdStream,
|
||||
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at 40px,
|
||||
// gradient ending at 45% alpha (not the hero's 30%).
|
||||
barras: 30,
|
||||
color: tokens.warmCoral,
|
||||
altura: 40,
|
||||
barrasDiscretas: true,
|
||||
gradienteFinAlpha: 0.45,
|
||||
),
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
|
||||
const SizedBox(height: 22),
|
||||
_Controles(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
esFavorito: esFavorito,
|
||||
).pluriFadeSlideIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 310),
|
||||
beginY: 0.3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_BandejaHerramientas(
|
||||
estado: estado,
|
||||
eq: eq,
|
||||
emisora: emisoraActiva,
|
||||
compartir: _compartir,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 350)),
|
||||
const SizedBox(height: 12),
|
||||
// Item 26 / audit 2.4 (t4:114-140): the quality row is LAST,
|
||||
// after the tool tray -- it used to sit right after the
|
||||
// subtitle, 4 positions too early.
|
||||
_FilaCalidad(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 390)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -201,25 +231,32 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||
|
||||
/// Square art (design proposal WU14 row: "square art" replaces the old
|
||||
/// circular `_WaveHero`). Loading/error overlays and the fallback icon are
|
||||
/// unchanged from the prior circular version — only the clip shape and the
|
||||
/// decorative halo geometry changed from circle to rounded-square.
|
||||
/// unchanged from the prior circular version.
|
||||
///
|
||||
/// Audit 2.1 (t4 line 114): the prototype draws a FIXED 246x246 square,
|
||||
/// radius 28, with only a drop shadow -- no halo, no ring. The previous
|
||||
/// `width*0.62`-derived size plus a radial-gradient halo Container and a
|
||||
/// bordered ring Container (neither present in the prototype) are both
|
||||
/// removed; `_radio` (28) is deliberately its own constant, distinct from
|
||||
/// every named token radius, mirroring this codebase's established
|
||||
/// one-off-radius precedent (e.g. the ringing screen's stop button).
|
||||
class _ArteReproductor extends StatelessWidget {
|
||||
final Emisora emisora;
|
||||
final Stream<EstadoReproduccion> estadoStream;
|
||||
|
||||
const _ArteReproductor({required this.emisora, required this.estadoStream});
|
||||
|
||||
static const double _lado = 246;
|
||||
static const double _radio = 28;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final t = context.pluriTokens;
|
||||
final size = MediaQuery.of(context).size.width * 0.62;
|
||||
final radio = BorderRadius.circular(t.radiusLg);
|
||||
final radio = BorderRadius.circular(_radio);
|
||||
|
||||
return StreamBuilder<EstadoReproduccion>(
|
||||
stream: estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final reproduciendo = snapshot.data == EstadoReproduccion.reproduciendo;
|
||||
// S7-R3: reconectando renders as loading, never as error.
|
||||
final cargando =
|
||||
snapshot.data == EstadoReproduccion.cargando ||
|
||||
@@ -228,83 +265,62 @@ class _ArteReproductor extends StatelessWidget {
|
||||
|
||||
return SizedBox(
|
||||
key: const Key('player-hero-art'),
|
||||
width: size + 40,
|
||||
height: size + 40,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: size + 34,
|
||||
height: size + 34,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(t.radiusLg + 16),
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
t.electricMagenta.withValues(
|
||||
alpha: reproduciendo ? 0.35 : 0.18,
|
||||
),
|
||||
t.deepViolet.withValues(alpha: 0.0),
|
||||
],
|
||||
),
|
||||
width: _lado,
|
||||
height: _lado,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radio,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
blurRadius: 60,
|
||||
offset: const Offset(0, 26),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(t.radiusLg + 4),
|
||||
border: Border.all(color: t.glassBorder),
|
||||
),
|
||||
),
|
||||
PluriGlassSurface(
|
||||
],
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
borderRadius: radio,
|
||||
padding: EdgeInsets.zero,
|
||||
child: ClipRRect(
|
||||
borderRadius: radio,
|
||||
padding: EdgeInsets.zero,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: ClipRRect(
|
||||
borderRadius: radio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (emisora.favicon != null &&
|
||||
emisora.favicon!.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
)
|
||||
else
|
||||
_iconoFallback(theme),
|
||||
if (cargando)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmer(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
)
|
||||
else
|
||||
_iconoFallback(theme),
|
||||
if (cargando)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hayError)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
size: 56,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
),
|
||||
if (hayError)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
size: 56,
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -327,6 +343,63 @@ class _ArteReproductor extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
|
||||
/// CURRENT station's own art, sitting behind the whole screen. Unlike the
|
||||
/// ringing screen's `_FondoArteDifuminado` (which cannot use
|
||||
/// `Emisora.favicon` -- a cold-boot download after a screen-off wake would
|
||||
/// hang widget tests without a mocked `HttpClient`), this screen already
|
||||
/// renders `emisora.favicon` via `CachedNetworkImage` for its OWN foreground
|
||||
/// art (`_ArteReproductor` above) with no such hazard -- reusing it here for
|
||||
/// the backdrop is equally safe. Renders the gradient tint even without a
|
||||
/// favicon, so the page keeps the same base depth in every state.
|
||||
class _FondoReproductor extends StatelessWidget {
|
||||
const _FondoReproductor({required this.emisora, required this.tokens});
|
||||
|
||||
final Emisora emisora;
|
||||
final PluriWaveTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final favicon = emisora.favicon;
|
||||
return Positioned.fill(
|
||||
key: const ValueKey('player-background-art'),
|
||||
child: IgnorePointer(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (favicon != null && favicon.isNotEmpty)
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 26, sigmaY: 26),
|
||||
child: Opacity(
|
||||
opacity: 0.45,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: favicon,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
tokens.deepViolet.withValues(alpha: 0.55),
|
||||
tokens.deepViolet.withValues(alpha: 0.9),
|
||||
tokens.deepViolet,
|
||||
],
|
||||
stops: const [0, 0.46, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single subtitle line (WU14: collapses the old `_InfoChips` `Wrap` of
|
||||
/// separate chips — country/language now join as one line; codec/bitrate
|
||||
/// moved into their own [_FilaCalidad] row below).
|
||||
@@ -755,9 +828,10 @@ class _Controles extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
// Audit 2.9 (t4 lines 124-131): the transport row sits directly on
|
||||
// the background -- the prototype draws no wrapping card here.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
@@ -874,6 +948,13 @@ class _Controles extends StatelessWidget {
|
||||
/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer
|
||||
/// / Compartir), each opening its own bottom sheet (Compartir invokes
|
||||
/// directly instead — there is no sheet content for it).
|
||||
///
|
||||
/// Audit 2.8 (t4 lines 133-136): only the FIRST tile ("EQ propio") is
|
||||
/// accented -- tinted background/border, brand-colour icon, bold brand
|
||||
/// label. The other three share a neutral OPAQUE surface (not `listSurface`
|
||||
/// at 55% alpha) with a full-weight icon/label -- this app's ambient
|
||||
/// default colour, not the `tokens.electricMagenta` fallback every tile
|
||||
/// used to render in regardless of [accent].
|
||||
class _TileHerramienta extends StatelessWidget {
|
||||
const _TileHerramienta({
|
||||
super.key,
|
||||
@@ -881,6 +962,7 @@ class _TileHerramienta extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.iconColor,
|
||||
this.accent = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -888,26 +970,42 @@ class _TileHerramienta extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final Color? iconColor;
|
||||
|
||||
/// See class doc — `false` (neutral) is every tile except "EQ propio".
|
||||
final bool accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.pluriTokens;
|
||||
final resolvedIconColor =
|
||||
iconColor ?? (accent ? tokens.electricMagenta : null);
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.listSurface.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
color:
|
||||
accent
|
||||
? tokens.electricMagenta.withValues(alpha: 0.16)
|
||||
: tokens.listSurface,
|
||||
// Audit 2.8 (t4 lines 133-136): radius 18 for BOTH variants --
|
||||
// `radiusMd`, not the smaller `radiusSm` every tile used before.
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
border: Border.all(
|
||||
color:
|
||||
accent
|
||||
? tokens.electricMagenta.withValues(alpha: 0.4)
|
||||
: Colors.white.withValues(alpha: 0.09),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 23, color: iconColor ?? tokens.electricMagenta),
|
||||
Icon(icon, size: 23, color: resolvedIconColor),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
label,
|
||||
@@ -915,7 +1013,11 @@ class _TileHerramienta extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: accent ? FontWeight.w800 : FontWeight.w700,
|
||||
color:
|
||||
accent
|
||||
? tokens.electricMagenta
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -952,6 +1054,7 @@ class _BandejaHerramientas extends StatelessWidget {
|
||||
icon: Icons.equalizer_rounded,
|
||||
label: l10n.playerToolEqLabel,
|
||||
onTap: () => _mostrarHojaEq(context, eq, emisora),
|
||||
accent: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
Reference in New Issue
Block a user