import 'package:flutter/material.dart'; import '../tema/pluriwave_theme.dart'; import 'pluri_icon.dart'; class PluriNavItem { const PluriNavItem({required this.glyph, required this.label}); final PluriIconGlyph glyph; 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, required this.items, required this.selectedIndex, required this.onSelected, }); final List items; 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; 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, required this.selected, required this.onTap, }); final PluriNavItem item; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final t = context.pluriTokens; final motion = context.pluriMotion; return Semantics( button: true, selected: selected, label: item.label, child: Material( type: MaterialType.transparency, child: InkWell( onTap: onTap, // Without a shape the ink splash and hover highlight paint as a // full-bleed RECTANGLE over the cell — a hard square sitting on // top of the icon, which is what users reported. The whole bar is // built on 999-radius pills; the splash has to follow. borderRadius: BorderRadius.circular(999), 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( // t4/4a spec: active item lift `translateY(-15px)`. Every // property here used to change INSTANTLY while the balloon // behind it slid with AnimatedPositioned — the balloon // glided and its contents teleported, which read as a // broken transition. All four now share the balloon's own // duration and curve so the whole tab moves as one. child: AnimatedSlide( duration: motion.normal, curve: Curves.easeOutCubic, // Slide is expressed in fractions of the child's size; // the icon column is ~40 tall, so -15px is about -0.375. offset: Offset(0, selected ? -0.375 : 0), child: AnimatedSize( duration: motion.normal, curve: Curves.easeOutCubic, child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedOpacity( duration: motion.normal, curve: Curves.easeOutCubic, // 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: TweenAnimationBuilder( duration: motion.normal, curve: Curves.easeOutCubic, tween: Tween(end: selected ? 25 : 23), builder: (context, size, _) => PluriIcon( glyph: item.glyph, variant: PluriIconVariant.filled, // t4/4a spec: `font-size:25px`/`23px`. size: size, color: selected ? t.electricMagenta : null, // Same ARB string the outer Semantics // already uses — passing it explicitly // skips PluriIcon's own // AppLocalizations.of lookup. 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, ), ), ], ], ), ), ), ), ), ), ), ), ), ); } }