diff --git a/lib/tema/pluriwave_tokens.dart b/lib/tema/pluriwave_tokens.dart index 3f381d7..963193d 100644 --- a/lib/tema/pluriwave_tokens.dart +++ b/lib/tema/pluriwave_tokens.dart @@ -14,6 +14,7 @@ class PluriWaveTokens extends ThemeExtension { required this.listSurface, required this.liveGreen, required this.offlineAccent, + required this.balloonSurface, required this.radiusSm, required this.radiusMd, required this.radiusLg, @@ -46,6 +47,12 @@ class PluriWaveTokens extends ThemeExtension { /// promote, but the hex value itself is not this file's discretion. final Color offlineAccent; + /// Solid capsule fill for the "barra globo" bottom navigation (Design turn + /// t4, option 4a — the balloon and the flat bar share this colour so their + /// union reads as a single inflated shape). No prior literal to promote — + /// new brand-system near-black distinct from [deepViolet]. + final Color balloonSurface; + final double radiusSm; final double radiusMd; final double radiusLg; @@ -75,6 +82,7 @@ class PluriWaveTokens extends ThemeExtension { listSurface: Color(0xFF102532), liveGreen: Color(0xFF7EE4C2), offlineAccent: Color(0xFFE8879A), + balloonSurface: Color(0xFF0A1B24), radiusSm: 14, radiusMd: 22, radiusLg: 30, @@ -95,6 +103,7 @@ class PluriWaveTokens extends ThemeExtension { Color? listSurface, Color? liveGreen, Color? offlineAccent, + Color? balloonSurface, double? radiusSm, double? radiusMd, double? radiusLg, @@ -113,6 +122,7 @@ class PluriWaveTokens extends ThemeExtension { listSurface: listSurface ?? this.listSurface, liveGreen: liveGreen ?? this.liveGreen, offlineAccent: offlineAccent ?? this.offlineAccent, + balloonSurface: balloonSurface ?? this.balloonSurface, radiusSm: radiusSm ?? this.radiusSm, radiusMd: radiusMd ?? this.radiusMd, radiusLg: radiusLg ?? this.radiusLg, @@ -143,6 +153,9 @@ class PluriWaveTokens extends ThemeExtension { liveGreen: Color.lerp(liveGreen, other.liveGreen, t) ?? liveGreen, offlineAccent: Color.lerp(offlineAccent, other.offlineAccent, t) ?? offlineAccent, + balloonSurface: + Color.lerp(balloonSurface, other.balloonSurface, t) ?? + balloonSurface, radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm, radiusMd: lerpDouble(radiusMd, other.radiusMd, t) ?? radiusMd, radiusLg: lerpDouble(radiusLg, other.radiusLg, t) ?? radiusLg, diff --git a/lib/widgets/pluri_bottom_navigation.dart b/lib/widgets/pluri_bottom_navigation.dart index ac0c49a..40d1fd1 100644 --- a/lib/widgets/pluri_bottom_navigation.dart +++ b/lib/widgets/pluri_bottom_navigation.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../tema/pluriwave_theme.dart'; -import 'pluri_glass_surface.dart'; import 'pluri_icon.dart'; class PluriNavItem { @@ -11,6 +10,13 @@ class PluriNavItem { final String label; } +/// "Barra globo" bottom navigation — Design turn t4, option 4a. Spec +/// transcribed verbatim from `PluriWave Rediseno.dc.html` lines 91-99: a +/// solid capsule bar with a taller "balloon" capsule that slides beneath +/// whichever tab is active. The balloon and the bar share one fill colour +/// ([PluriWaveTokens.balloonSurface]) so their union reads as a single +/// inflated shape — same design vocabulary as the prototype's own +/// `balloon()` helper (`PluriWave Rediseno.dc.html:2130-2155`). class PluriBottomNavigation extends StatelessWidget { const PluriBottomNavigation({ super.key, @@ -23,30 +29,177 @@ class PluriBottomNavigation extends StatelessWidget { final int selectedIndex; final ValueChanged onSelected; + /// t4/4a spec: container `height:74px`. Fixed by construction (the root is + /// a `SizedBox` of this exact height, not content-driven), so this is also + /// what a real `tester.getSize` measurement returns — see + /// `pluri_bottom_navigation_test.dart`'s measurement assertion, which + /// backs `PluriLayout.bottomChromeInset`. + static const double altura = 74; + + /// t4/4a spec: `width:110px` for the glow/balloon/teal-wash layers, + /// verbatim — independent of tab count (the prototype's own screen uses 4 + /// cells; this app has 5, so the balloon is wider than one cell and + /// intentionally overlaps its neighbours, matching the literal spec + /// numbers rather than rescaling them). + static const double _balloonWidth = 110; + + /// Test hook for the balloon's own positioned box. + @visibleForTesting + static const balloonKey = ValueKey('pluriBottomNavigationBalloon'); + + /// Test hook for a given tab's tappable/semantics region. + @visibleForTesting + static Key itemKey(int index) => ValueKey('pluriBottomNavigationItem_$index'); + @override Widget build(BuildContext context) { final t = context.pluriTokens; - return PluriGlassSurface( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 7), - borderRadius: BorderRadius.circular(999), - glowColor: t.glowColor.withValues(alpha: 0.28), - child: Row( - children: [ - for (var i = 0; i < items.length; i++) - Expanded( - flex: i == selectedIndex ? 15 : 10, - child: _PluriNavButton( - item: items[i], - selected: i == selectedIndex, - onTap: () => onSelected(i), + final motion = context.pluriMotion; + return SizedBox( + height: altura, + child: LayoutBuilder( + builder: (context, constraints) { + final cellWidth = constraints.maxWidth / items.length; + final balloonLeft = + cellWidth * selectedIndex + (cellWidth - _balloonWidth) / 2; + + return Stack( + clipBehavior: Clip.none, + children: [ + // Layer 1: radial glow behind the active balloon. + AnimatedPositioned( + duration: motion.normal, + curve: Curves.easeOutCubic, + left: balloonLeft, + bottom: -8, + width: _balloonWidth, + height: 92, + child: IgnorePointer( + child: DecoratedBox( + // Spec: `border-radius:50%` on a 110x92 box, i.e. an + // ellipse — Flutter's `BoxShape.circle` only supports + // equal-radius circles (it would clip this non-square + // box down to a 92-diameter circle, losing width). The + // radial gradient already fades to fully transparent at + // 66% of its radius, well before any box edge, so the + // rectangular (unclipped) paint area is visually + // indistinguishable from a true ellipse clip here. + decoration: BoxDecoration( + gradient: RadialGradient( + colors: [ + t.electricMagenta.withValues(alpha: 0.32), + t.electricMagenta.withValues(alpha: 0), + ], + stops: const [0, 0.66], + ), + ), + ), + ), ), - ), - ], + // Layer 2 (shadow layer): the flat bar and the balloon, both + // filled with the same solid colour and carrying the same two + // drop-shadows, so their overlap reads as one union. + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 52, + child: DecoratedBox( + decoration: BoxDecoration( + color: t.balloonSurface, + borderRadius: BorderRadius.circular(999), + boxShadow: _shellShadows, + ), + ), + ), + AnimatedPositioned( + key: balloonKey, + duration: motion.normal, + curve: Curves.easeOutCubic, + left: balloonLeft, + bottom: 0, + width: _balloonWidth, + height: 74, + child: DecoratedBox( + decoration: BoxDecoration( + color: t.balloonSurface, + borderRadius: BorderRadius.circular(999), + boxShadow: _shellShadows, + ), + ), + ), + // Layer 3: teal wash over the balloon only. + AnimatedPositioned( + duration: motion.normal, + curve: Curves.easeOutCubic, + left: balloonLeft, + bottom: 0, + width: _balloonWidth, + height: 74, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + t.electricMagenta.withValues(alpha: 0.2), + t.electricMagenta.withValues(alpha: 0), + ], + stops: const [0, 0.62], + ), + ), + ), + ), + ), + // Layer 4: the row of items, topmost and interactive. Spans + // the FULL container height (not just the 52px bar strip) so + // the active tab's lifted icon/label — which visually pokes + // up into the balloon's extra headroom — stays within its own + // tap/semantics region instead of falling through to the + // (non-interactive) balloon shape behind it. + Positioned.fill( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < items.length; i++) + Expanded( + key: itemKey(i), + child: _PluriNavButton( + item: items[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + ), + ], + ), + ), + ], + ); + }, ), ); } } +/// t4/4a spec: `drop-shadow(0 -1.5px 0 rgba(255,255,255,.17))` (a hard-edged +/// rim highlight, zero blur) plus `drop-shadow(0 14px 26px rgba(0,0,0,.5))` +/// (a soft downward shadow). CSS applies these to the *union* of the bar and +/// balloon via one shared `filter`; Flutter has no direct primitive for a +/// shadow-of-a-path-union, so the same two shadows are applied to both +/// shapes individually — since both are solid, identically-coloured pills, +/// the balloon's opaque fill already covers the seam where the bar's own +/// shadow would otherwise show through. +List get _shellShadows => [ + BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)), + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + offset: const Offset(0, 14), + blurRadius: 26, + ), +]; + class _PluriNavButton extends StatelessWidget { const _PluriNavButton({ required this.item, @@ -61,91 +214,82 @@ class _PluriNavButton extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.pluriTokens; - final foreground = Theme.of(context).colorScheme.onSurface; return Semantics( button: true, selected: selected, label: item.label, - child: AnimatedContainer( - duration: context.pluriMotion.normal, - curve: Curves.easeOutCubic, - margin: EdgeInsets.symmetric(horizontal: selected ? 3 : 2), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - gradient: - selected - ? LinearGradient( - colors: [ - t.electricMagenta.withValues(alpha: 0.32), - t.warmCoral.withValues(alpha: 0.18), - ], - ) - : null, - border: Border.all( - color: - selected - ? Colors.white.withValues(alpha: 0.22) - : Colors.white.withValues(alpha: 0.06), - ), - boxShadow: - selected - ? [ - BoxShadow( - color: t.glowColor.withValues(alpha: 0.36), - blurRadius: 24, - spreadRadius: -6, - offset: const Offset(0, 8), - ), - ] - : const [], - ), + child: Material( + type: MaterialType.transparency, child: InkWell( - borderRadius: BorderRadius.circular(999), onTap: onTap, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: selected ? 8 : 3, - vertical: selected ? 8 : 7, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedScale( - scale: selected ? 1.16 : 0.96, - duration: context.pluriMotion.quick, - curve: Curves.easeOutBack, - child: PluriIcon( - glyph: item.glyph, - variant: - selected - ? PluriIconVariant.activeGlow - : PluriIconVariant.filled, - size: selected ? 42 : 34, + child: Align( + alignment: Alignment.bottomCenter, + // t4/4a spec: the items row itself is 52px tall — this inner + // box reproduces that visual strip even though the outer + // tappable region now spans the full 74px balloon area. + child: SizedBox( + height: 52, + child: Center( + // The outer Semantics(button:.., label: item.label) already + // fully describes this control. Without this, PluriIcon's + // own inner Semantics(image:true) node (and, for the active + // tab, Text's auto-generated semantics) has no container + // boundary to stop it merging into the button's node — + // Flutter joins merged labels with `\n`, so screen readers + // would announce "Alarmas\nAlarmas" instead of "Alarmas". + child: ExcludeSemantics( + child: Transform.translate( + // t4/4a spec: active item lift `translateY(-15px)`. + offset: Offset(0, selected ? -15 : 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + // t4/4a spec: active icon full colour; inactive + // `rgba(242,247,250,.46)` — .46 applied here as + // uniform opacity dims both the fallback Icon + // (already `onSurface` from + // PluriIconVariant.filled) and the real raster + // badge asset identically. + opacity: selected ? 1 : 0.46, + child: PluriIcon( + glyph: item.glyph, + variant: PluriIconVariant.filled, + // t4/4a spec: icon `font-size:25px`/`23px`. + size: selected ? 25 : 23, + color: selected ? t.electricMagenta : null, + // Same ARB string the outer Semantics already + // uses — passing it explicitly skips + // PluriIcon's own AppLocalizations.of lookup + // (excluded from the tree above regardless). + semanticLabel: item.label, + ), + ), + if (selected) ...[ + const SizedBox(height: 3), + Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + // t4/4a spec: label `font-size:11px; + // font-weight:800; line-height:1.25`, brand + // colour. + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith( + fontSize: 11, + fontWeight: FontWeight.w800, + height: 1.25, + letterSpacing: 0, + color: t.electricMagenta, + ), + ), + ], + ], + ), ), ), - AnimatedSize( - duration: context.pluriMotion.quick, - curve: Curves.easeOutCubic, - child: - selected - ? Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith( - color: foreground, - fontWeight: FontWeight.w900, - letterSpacing: -0.2, - ), - ), - ) - : const SizedBox.shrink(), - ), - ], + ), ), ), ), diff --git a/lib/widgets/pluri_icon.dart b/lib/widgets/pluri_icon.dart index a3e64fd..747f787 100644 --- a/lib/widgets/pluri_icon.dart +++ b/lib/widgets/pluri_icon.dart @@ -15,6 +15,7 @@ class PluriIcon extends StatelessWidget { this.variant = PluriIconVariant.outline, this.size = 24, this.semanticLabel, + this.color, }); final PluriIconGlyph glyph; @@ -22,6 +23,12 @@ class PluriIcon extends StatelessWidget { final double size; final String? semanticLabel; + /// Explicit colour override for the Icon-fallback render path (used when + /// the raster asset fails to decode). `null` (default) preserves the + /// existing variant-driven colour for every current call site — see + /// `_resolveColor`. + final Color? color; + @override Widget build(BuildContext context) { final tokens = context.pluriTokens; @@ -82,6 +89,7 @@ class PluriIcon extends StatelessWidget { } Color _resolveColor(BuildContext context, PluriWaveTokens tokens) { + if (color != null) return color!; if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta; if (variant == PluriIconVariant.filled) { return Theme.of(context).colorScheme.onSurface; diff --git a/lib/widgets/pluri_layout.dart b/lib/widgets/pluri_layout.dart index 69f6497..0bffd11 100644 --- a/lib/widgets/pluri_layout.dart +++ b/lib/widgets/pluri_layout.dart @@ -2,13 +2,22 @@ import '../tema/pluriwave_theme.dart'; import 'mini_reproductor.dart'; +import 'pluri_bottom_navigation.dart'; abstract final class PluriLayout { static const double horizontal = 16; static const double sectionGap = 12; static const double panelGap = 12; static const double compactGap = 8; - static const double bottomChromeInset = 146; + + /// `app.dart`'s `bottomNavigationBar` stacks `MiniReproductor` above + /// `PluriBottomNavigation` with no gap between them, wrapped in a + /// `SafeArea(minimum: EdgeInsets.only(bottom: compactGap))` — this is that + /// same sum, derived from the two chrome widgets' own measured heights + /// instead of a guessed constant. See + /// `pluri_bottom_navigation_test.dart`'s composed-chrome-height assertion. + static const double bottomChromeInset = + MiniReproductor.altura + PluriBottomNavigation.altura + compactGap; /// ADR-7(b): `bottomChromeInset` assumes `MiniReproductor` is visible. /// Escuchar hides it (design's one exception — the embedded hero already diff --git a/test/widgets/pluri_bottom_navigation_test.dart b/test/widgets/pluri_bottom_navigation_test.dart new file mode 100644 index 0000000..6c9a846 --- /dev/null +++ b/test/widgets/pluri_bottom_navigation_test.dart @@ -0,0 +1,287 @@ +import 'dart:ui' show Tristate; + +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/pluri_bottom_navigation.dart'; +import 'package:pluriwave/widgets/pluri_icon.dart'; +import 'package:pluriwave/widgets/pluri_layout.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; + +/// "Barra globo" bottom navigation — Design turn t4, option 4a. Spec +/// transcribed verbatim from `PluriWave Rediseno.dc.html` lines 91-99: a +/// solid capsule bar with a taller balloon capsule that slides beneath +/// whichever tab is active. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + const items = [ + PluriNavItem(glyph: PluriIconGlyph.home, label: 'Escuchar'), + PluriNavItem(glyph: PluriIconGlyph.search, label: 'Buscar'), + PluriNavItem(glyph: PluriIconGlyph.favorites, label: 'Favoritos'), + PluriNavItem(glyph: PluriIconGlyph.alarm, label: 'Alarmas'), + PluriNavItem(glyph: PluriIconGlyph.settings, label: 'Ajustes'), + ]; + + Widget hostFor(int selectedIndex, {ValueChanged? onSelected}) { + return MaterialApp( + theme: PluriWaveTheme.dark(), + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 390, + child: PluriBottomNavigation( + items: items, + selectedIndex: selectedIndex, + onSelected: onSelected ?? (_) {}, + ), + ), + ), + ); + } + + testWidgets('only the active tab renders a visible text label', ( + tester, + ) async { + await tester.pumpWidget(hostFor(1)); + await tester.pump(); + + expect(find.text('Buscar'), findsOneWidget); + expect(find.text('Escuchar'), findsNothing); + expect(find.text('Favoritos'), findsNothing); + expect(find.text('Alarmas'), findsNothing); + expect(find.text('Ajustes'), findsNothing); + }); + + testWidgets('active icon and label use the brand colour (#21D4D9)', ( + tester, + ) async { + await tester.pumpWidget(hostFor(2)); + await tester.pump(); + + final activeIcon = tester.widget( + find.descendant( + of: find.byKey(PluriBottomNavigation.itemKey(2)), + matching: find.byType(PluriIcon), + ), + ); + expect(activeIcon.color, PluriWaveTokens.brand); + + final label = tester.widget(find.text('Favoritos')); + expect(label.style?.color, PluriWaveTokens.brand); + }); + + testWidgets('inactive icons are dimmed to 46% and carry no colour override', ( + tester, + ) async { + await tester.pumpWidget(hostFor(0)); + await tester.pump(); + + final inactiveIcon = tester.widget( + find.descendant( + of: find.byKey(PluriBottomNavigation.itemKey(1)), + matching: find.byType(PluriIcon), + ), + ); + expect(inactiveIcon.color, isNull); + + final dimmed = tester.widget( + find.ancestor( + of: find.descendant( + of: find.byKey(PluriBottomNavigation.itemKey(1)), + matching: find.byType(PluriIcon), + ), + matching: find.byType(Opacity), + ), + ); + expect(dimmed.opacity, closeTo(0.46, 0.001)); + }); + + testWidgets( + 'the balloon exists, sized per spec, and centred over the active tab', + (tester) async { + await tester.pumpWidget(hostFor(0)); + await tester.pump(); + + final balloon = find.byKey(PluriBottomNavigation.balloonKey); + expect(balloon, findsOneWidget); + expect(tester.getSize(balloon), const Size(110, 74)); + + final balloonCenterX = tester.getCenter(balloon).dx; + final tabCenterX = + tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(0))).dx; + expect(balloonCenterX, closeTo(tabCenterX, 1)); + + final decoratedBox = tester.widget( + find.descendant(of: balloon, matching: find.byType(DecoratedBox)), + ); + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.color, PluriWaveTokens.dark.balloonSurface); + }, + ); + + testWidgets('selection changes slide the balloon to the newly active tab', ( + tester, + ) async { + var selected = 0; + await tester.pumpWidget( + StatefulBuilder( + builder: + (context, setState) => MaterialApp( + theme: PluriWaveTheme.dark(), + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 390, + child: PluriBottomNavigation( + items: items, + selectedIndex: selected, + onSelected: (i) => setState(() => selected = i), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final initialX = + tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx; + + await tester.tap(find.byKey(PluriBottomNavigation.itemKey(3))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 260)); + + final finalX = + tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx; + expect(finalX, isNot(closeTo(initialX, 1))); + expect( + finalX, + closeTo( + tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(3))).dx, + 1, + ), + ); + }); + + testWidgets('inactive tabs keep their semantic label without visible text', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(hostFor(0)); + await tester.pump(); + + final node = tester.getSemantics( + find.byKey(PluriBottomNavigation.itemKey(3)), + ); + expect(node.label, 'Alarmas'); + expect(node.flagsCollection.isButton, isTrue); + expect(node.flagsCollection.isSelected, Tristate.isFalse); + + semantics.dispose(); + }); + + 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)), + ); + expect(size.width, greaterThanOrEqualTo(48)); + expect(size.height, greaterThanOrEqualTo(48)); + } + }); + + testWidgets('PluriBottomNavigation.altura matches its real laid-out height', ( + tester, + ) async { + await tester.pumpWidget(hostFor(0)); + await tester.pump(); + + final real = tester.getSize(find.byType(PluriBottomNavigation)).height; + expect(PluriBottomNavigation.altura, closeTo(real, 0.5)); + }); + + testWidgets( + 'PluriLayout.bottomChromeInset matches the real composed chrome height ' + '(MiniReproductor + PluriBottomNavigation + the safe-area gap)', + (tester) async { + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A')); + + const chromeKey = Key('chromeSafeArea'); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: MaterialApp( + theme: PluriWaveTheme.dark(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: const SizedBox.shrink(), + 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( + items: items, + selectedIndex: 0, + onSelected: (_) {}, + ), + ], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final medido = tester.getSize(find.byKey(chromeKey)).height; + expect( + PluriLayout.bottomChromeInset, + closeTo(medido, 4), + reason: + 'bottomChromeInset must be derived from the real composed chrome ' + 'height (MiniReproductor + PluriBottomNavigation + the ' + 'safe-area gap), not guessed — if this fails, re-measure and ' + 'update the formula in pluri_layout.dart', + ); + }, + ); +}