From 4537497e8330f71e20bb579a3a18b57e7a4aeb2b Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 10:38:59 +0200 Subject: [PATCH] fix(chrome): rebuild MiniReproductor as a full-bleed opaque bar with art Item 22 / audit 3.6 (t4:184-188): the mini player was a floating 999-radius glass pill with no artwork. Replace it with a 60px opaque bar (listSurface at .97 alpha), full-bleed edge to edge, showing the station's square 42x42 artwork instead of the abstract playing-bars indicator. app.dart no longer wraps the bar in the balloon nav's own 8px side margin, so it now spans the full width. MiniReproductor.altura is re-measured (72 -> 60) now that the bar's content height is fixed by construction; PluriLayout.bottomChromeInset derives from it as before. Both the S3-R3 configurarLocalizaciones guard and the altura measurement test still pass unmodified. --- lib/app.dart | 29 +- lib/widgets/mini_reproductor.dart | 353 +++++++++++------- test/widgets/mini_reproductor_barra_test.dart | 141 +++++++ .../widgets/pluri_bottom_navigation_test.dart | 33 +- 4 files changed, 386 insertions(+), 170 deletions(-) create mode 100644 test/widgets/mini_reproductor_barra_test.dart diff --git a/lib/app.dart b/lib/app.dart index 7547a7d..d5eb8e3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -243,23 +243,26 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> bottomNavigationBar: SafeArea( top: false, minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 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( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Item 22 / audit 3.6 (t4:185 left:0;right:0): the mini player + // is full-bleed — it does NOT share the balloon bar's 8px side + // margin below. 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), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), + child: PluriBottomNavigation( items: _navItems(l10n), selectedIndex: indice, onSelected: (i) => navegacion.irA(RaizPluriWave.values[i]), ), - ], - ), + ), + ], ), ), ); diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index eeaac5d..a7e126c 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -1,18 +1,22 @@ +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_radio.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; +import '../modelos/emisora.dart'; import '../pantallas/pantalla_reproductor.dart'; import '../servicios/servicio_audio.dart'; import '../tema/pluriwave_theme.dart'; -import 'pluri_glass_surface.dart'; -import 'pluri_icon.dart'; -import 'visualizador_audio.dart'; /// Barra inferior persistente con controles básicos de reproducción. /// Toca la barra para abrir PantallaReproductor completa. +/// +/// Item 22 / audit 3.6 (t4:184-188): a 60px full-bleed opaque bar with +/// station artwork -- replacing the former floating 999-radius glass pill, +/// which had no artwork at all. class MiniReproductor extends StatefulWidget { const MiniReproductor({super.key, this.visible = true}); @@ -24,11 +28,12 @@ class MiniReproductor extends StatefulWidget { /// 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 + /// Measured (not guessed) from this widget's actual laid-out height — see /// `mini_reproductor_configurar_test.dart`'s measurement assertion. Backs - /// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)). - static const double altura = 72; + /// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)). Item 22 fixes this + /// bar's content to a t4:184 `height:60px` container, so the measured + /// height is deterministic regardless of station-name text metrics. + static const double altura = 60; @override State createState() => _MiniReproductorState(); @@ -62,53 +67,39 @@ class _MiniReproductorState extends State { final t = context.pluriTokens; final stationName = localizedStationName(l10n, emisora.nombre); - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB( - t.spacingMd, - t.spacingSm, - t.spacingMd, - t.spacingSm, + // Item 22 / audit 3.6 (t4:184-188): a 60px, full-bleed, OPAQUE bar + // (rgba(16,37,50,.97) == listSurface at .97 alpha) with a top hairline — + // no BackdropFilter, no side margins, no pill radius. `app.dart` no + // longer wraps this widget in its own horizontal padding either (see + // its `bottomNavigationBar` composition). + return DecoratedBox( + key: const ValueKey('mini-reproductor-superficie'), + decoration: BoxDecoration( + color: t.listSurface.withValues(alpha: 0.97), + border: Border( + top: BorderSide(color: Colors.white.withValues(alpha: 0.07)), ), - child: PluriGlassSurface( - // S3 (Tier 1 visual fidelity): chrome — one of the system rule's - // two named exceptions to the opaque list-surface default. - glass: true, - padding: EdgeInsets.symmetric( - horizontal: t.spacingSm, - vertical: t.spacingXs, - ), - borderRadius: BorderRadius.circular(999), - child: Row( - children: [ - Expanded( - child: Semantics( - button: true, - label: l10n.miniPlayerOpenLabel(stationName), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(999), - onTap: () => PantallaReproductor.abrir(context, emisora), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: t.spacingXs, - vertical: t.spacingXs, - ), + ), + child: SafeArea( + top: false, + child: SizedBox( + height: 60, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Expanded( + child: Semantics( + button: true, + label: l10n.miniPlayerOpenLabel(stationName), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: + () => PantallaReproductor.abrir(context, emisora), child: Row( children: [ - SizedBox( - width: 40, - height: 40, - child: Center( - child: IndicadorReproduccion( - estadoStream: estado.estadoStream, - color: t.electricMagenta, - size: 20, - ), - ), - ), + _ArteMiniReproductor(emisora: emisora), SizedBox(width: t.spacingSm), Expanded( child: Column( @@ -141,113 +132,147 @@ class _MiniReproductorState extends State { s == EstadoReproduccion.reconectando || s == EstadoReproduccion.error; - return Text( - _labelEstado(l10n, s), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: - conexionEnProblema - ? t.offlineAccent - : activo - ? t.warmCoral - : Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.7), - fontWeight: - (activo || conexionEnProblema) - ? FontWeight.w600 - : FontWeight.w400, - ), + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // t4:187: a live station gets a + // small teal dot before its label + // ("En directo"). Static, not the + // prototype's `pw-pulse` animation + // — an infinite AnimationController + // here would hang every existing + // `pumpAndSettle()` call in this + // widget's own test suite that + // exercises a playing station. + if (activo) ...[ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only( + right: 5, + ), + decoration: BoxDecoration( + color: t.liveGreen, + shape: BoxShape.circle, + ), + ), + ], + Flexible( + child: Text( + _labelEstado(l10n, s), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + conexionEnProblema + ? t.offlineAccent + : activo + ? t.liveGreen + : Theme.of(context) + .colorScheme + .onSurface + .withValues( + alpha: 0.7, + ), + fontWeight: + (activo || + conexionEnProblema) + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + ], ); }, ), ], ), ), - PluriIcon( - glyph: PluriIconGlyph.player, - variant: PluriIconVariant.activeGlow, - size: 18, - semanticLabel: l10n.playerIconLabel, - ), ], ), ), ), ), ), - ), - StreamBuilder( - stream: estado.estadoStream, - builder: (context, snapshot) { - final s = snapshot.data ?? EstadoReproduccion.detenido; - // S7-R3: reconectando is a transient stall — render it like - // cargando (spinner), never as the error/retry affordance. - if (s == EstadoReproduccion.cargando || - s == EstadoReproduccion.reconectando) { - // WU16: only the reconectando sub-state gets the offline - // accent — plain cargando (e.g. the very first play) - // keeps the default spinner colour, since it is not a - // connectivity problem. - final reconectando = s == EstadoReproduccion.reconectando; - return SizedBox( - width: 48, - height: 48, - child: Padding( - padding: const EdgeInsets.all(12), - child: CircularProgressIndicator( - strokeWidth: 2, - color: reconectando ? t.offlineAccent : null, - ), - ), - ); - } - - if (s == EstadoReproduccion.error) { - final emisoraActual = estado.emisoraActual; - return IconButton( - tooltip: l10n.retryAction, - icon: Icon(Icons.refresh_rounded, color: t.offlineAccent), - onPressed: - emisoraActual != null - ? () => estado.reproducir(emisoraActual) - : null, - constraints: const BoxConstraints.tightFor( + StreamBuilder( + stream: estado.estadoStream, + builder: (context, snapshot) { + final s = snapshot.data ?? EstadoReproduccion.detenido; + // S7-R3: reconectando is a transient stall — render it + // like cargando (spinner), never as the error/retry + // affordance. + if (s == EstadoReproduccion.cargando || + s == EstadoReproduccion.reconectando) { + // WU16: only the reconectando sub-state gets the + // offline accent — plain cargando (e.g. the very first + // play) keeps the default spinner colour, since it is + // not a connectivity problem. + final reconectando = s == EstadoReproduccion.reconectando; + return SizedBox( width: 48, height: 48, - ), - ); - } + child: Padding( + padding: const EdgeInsets.all(12), + child: CircularProgressIndicator( + strokeWidth: 2, + color: reconectando ? t.offlineAccent : null, + ), + ), + ); + } - return Semantics( - button: true, - label: - s == EstadoReproduccion.reproduciendo - ? l10n.pauseAction - : l10n.playAction, - child: IconButton( - tooltip: + if (s == EstadoReproduccion.error) { + final emisoraActual = estado.emisoraActual; + return IconButton( + tooltip: l10n.retryAction, + icon: Icon( + Icons.refresh_rounded, + color: t.offlineAccent, + ), + onPressed: + emisoraActual != null + ? () => estado.reproducir(emisoraActual) + : null, + constraints: const BoxConstraints.tightFor( + width: 48, + height: 48, + ), + ); + } + + return Semantics( + button: true, + label: s == EstadoReproduccion.reproduciendo ? l10n.pauseAction : l10n.playAction, - icon: Icon( - s == EstadoReproduccion.reproduciendo - ? Icons.pause_circle_filled_rounded - : Icons.play_circle_fill_rounded, - color: t.electricMagenta, + child: IconButton( + tooltip: + s == EstadoReproduccion.reproduciendo + ? l10n.pauseAction + : l10n.playAction, + // t4:188: pause_circle at 30px. + icon: Icon( + s == EstadoReproduccion.reproduciendo + ? Icons.pause_circle_filled_rounded + : Icons.play_circle_fill_rounded, + size: 30, + color: t.electricMagenta, + ), + onPressed: estado.togglePlay, + constraints: const BoxConstraints.tightFor( + width: 48, + height: 48, + ), ), - onPressed: estado.togglePlay, - constraints: const BoxConstraints.tightFor( - width: 48, - height: 48, - ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), ), ), @@ -265,3 +290,53 @@ class _MiniReproductorState extends State { }; } } + +/// Item 22 / audit 3.6 (t4:186): square station artwork, 42x42, radius 11 — +/// the element the former glass pill never rendered at all. Mirrors the +/// CachedNetworkImage/shimmer/fallback pattern already established by +/// `TarjetaEmisora` and `_ArteEscuchar`, sized for this bar specifically. +class _ArteMiniReproductor extends StatelessWidget { + const _ArteMiniReproductor({required this.emisora}); + + final Emisora emisora; + + static const _lado = 42.0; + static const _radio = 11.0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ClipRRect( + key: const ValueKey('mini-reproductor-arte'), + borderRadius: BorderRadius.circular(_radio), + child: SizedBox( + width: _lado, + height: _lado, + child: + (emisora.favicon != null && emisora.favicon!.isNotEmpty) + ? CachedNetworkImage( + imageUrl: emisora.favicon!, + fit: BoxFit.cover, + placeholder: (_, __) => _shimmer(theme), + errorWidget: (_, __, ___) => _iconoFallback(theme), + ) + : _iconoFallback(theme), + ), + ); + } + + Widget _shimmer(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: 20, + color: theme.colorScheme.onPrimaryContainer, + ), + ); +} diff --git a/test/widgets/mini_reproductor_barra_test.dart b/test/widgets/mini_reproductor_barra_test.dart new file mode 100644 index 0000000..bfa8fd0 --- /dev/null +++ b/test/widgets/mini_reproductor_barra_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/tema/pluriwave_theme.dart'; +import 'package:pluriwave/tema/pluriwave_tokens.dart'; +import 'package:pluriwave/widgets/mini_reproductor.dart'; +import 'package:pluriwave/widgets/visualizador_audio.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +EstadoRadio _estadoConEmisora() { + return EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); +} + +Widget _hostFor(EstadoRadio estado, {double width = 390}) { + return ChangeNotifierProvider.value( + value: estado, + child: MaterialApp( + theme: PluriWaveTheme.dark(), + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox(width: width, child: const MiniReproductor()), + ), + ), + ); +} + +/// Item 22 / audit 3.6 (t4:184-188): the mini player becomes a 60px +/// full-bleed opaque bar with station artwork, replacing the floating +/// 999-radius glass pill with no artwork. +void main() { + testWidgets( + 'shows the station artwork (42x42, radius 11) instead of the playing-' + 'bars indicator', + (tester) async { + final estado = _estadoConEmisora(); + addTearDown(estado.dispose); + await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); + + await tester.pumpWidget(_hostFor(estado)); + await tester.pump(); + + expect( + find.byType(IndicadorReproduccion), + findsNothing, + reason: 't4:186 replaces the playing-bars indicator with artwork', + ); + + final arte = find.byKey(const ValueKey('mini-reproductor-arte')); + expect(arte, findsOneWidget); + expect( + tester.getSize(arte), + const Size(42, 42), + reason: 't4:186 art is 42x42', + ); + + final clip = tester.widget(arte); + expect( + (clip.borderRadius as BorderRadius).topLeft, + const Radius.circular(11), + reason: 't4:186 art corner radius is 11', + ); + }, + ); + + testWidgets( + 'is opaque -- no BackdropFilter -- unlike the former glass pill', + (tester) async { + final estado = _estadoConEmisora(); + addTearDown(estado.dispose); + await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); + + await tester.pumpWidget(_hostFor(estado)); + await tester.pump(); + + expect( + find.descendant( + of: find.byType(MiniReproductor), + matching: find.byType(BackdropFilter), + ), + findsNothing, + reason: + 't4:185 the bar is opaque rgba(16,37,50,.97), not blurred glass', + ); + }, + ); + + testWidgets( + 'fill colour is listSurface at .97 alpha (t4:185 rgba(16,37,50,.97))', + (tester) async { + final estado = _estadoConEmisora(); + addTearDown(estado.dispose); + await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); + + await tester.pumpWidget(_hostFor(estado)); + await tester.pump(); + + final decorado = tester.widget( + find.byKey(const ValueKey('mini-reproductor-superficie')), + ); + final decoration = decorado.decoration as BoxDecoration; + expect( + decoration.color, + PluriWaveTokens.dark.listSurface.withValues(alpha: 0.97), + ); + }, + ); + + testWidgets('the opaque surface spans the full given width -- no internal ' + 'horizontal margin (t4:185 left:0;right:0)', (tester) async { + final estado = _estadoConEmisora(); + addTearDown(estado.dispose); + await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); + + await tester.pumpWidget(_hostFor(estado, width: 390)); + await tester.pump(); + + expect( + tester + .getSize(find.byKey(const ValueKey('mini-reproductor-superficie'))) + .width, + 390, + reason: + 't4:185 the bar is full-bleed, not a margined floating pill -- ' + 'the OLD implementation wrapped this in 24px of horizontal ' + 'padding per side (8 from app.dart + 16 internal)', + ); + }); +} diff --git a/test/widgets/pluri_bottom_navigation_test.dart b/test/widgets/pluri_bottom_navigation_test.dart index 6c9a846..29aa31e 100644 --- a/test/widgets/pluri_bottom_navigation_test.dart +++ b/test/widgets/pluri_bottom_navigation_test.dart @@ -194,16 +194,12 @@ void main() { semantics.dispose(); }); - testWidgets('every tab meets the 48x48dp minimum tap target', ( - tester, - ) async { + testWidgets('every tab meets the 48x48dp minimum tap target', (tester) async { await tester.pumpWidget(hostFor(0)); await tester.pump(); for (var i = 0; i < items.length; i++) { - final size = tester.getSize( - find.byKey(PluriBottomNavigation.itemKey(i)), - ); + final size = tester.getSize(find.byKey(PluriBottomNavigation.itemKey(i))); expect(size.width, greaterThanOrEqualTo(48)); expect(size.height, greaterThanOrEqualTo(48)); } @@ -248,22 +244,23 @@ void main() { bottomNavigationBar: SafeArea( key: chromeKey, top: false, - minimum: const EdgeInsets.only( - bottom: PluriLayout.compactGap, - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const MiniReproductor(), - PluriBottomNavigation( + minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Mirrors app.dart's real composition: item 22 / audit + // 3.6 made MiniReproductor full-bleed, so only + // PluriBottomNavigation keeps the 8px side margin. + const MiniReproductor(), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), + child: PluriBottomNavigation( items: items, selectedIndex: 0, onSelected: (_) {}, ), - ], - ), + ), + ], ), ), ),