feat(nav): rebuild the bottom bar as the prototype's balloon bar

The functional redesign never touched pluri_bottom_navigation.dart, so the
bar kept the old glass + magenta/coral language while every other surface
moved on. Rebuilds it to prototype t4/4a: a 52px opaque #0A1B24 pill with a
110x74 balloon raised over the active tab, a teal radial glow behind it, the
active item lifted 15px with its label, and inactive items reduced to a 23px
icon at 46% opacity.

The five tabs and their PluriIconGlyph icons are unchanged, per the standing
decision — only the bar's shape, colour and behaviour move.

altura and PluriLayout.bottomChromeInset are both asserted against the real
laid-out height rather than hardcoded guesses.
This commit is contained in:
2026-07-29 20:00:30 +02:00
parent c01c518541
commit b8f078bc14
5 changed files with 555 additions and 94 deletions
+13
View File
@@ -14,6 +14,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
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<PluriWaveTokens> {
/// 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<PluriWaveTokens> {
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<PluriWaveTokens> {
Color? listSurface,
Color? liveGreen,
Color? offlineAccent,
Color? balloonSurface,
double? radiusSm,
double? radiusMd,
double? radiusLg,
@@ -113,6 +122,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
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<PluriWaveTokens> {
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,
+237 -93
View File
@@ -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<int> 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<BoxShadow> 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(),
),
],
),
),
),
),
+8
View File
@@ -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;
+10 -1
View File
@@ -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