Two user-reported bugs from on-device testing. The favourites overflow menu carried `constraints: tightFor(38x42)`, which sizes the POPUP rather than the button -- every item was clipped to its first letter, so users saw "M" and "E" instead of the labels. The existing test passed throughout because find.text matches a Text widget whether or not it is visually clipped; the new guard measures the laid-out width instead. The bottom bar's ink splash had no shape, painting a hard square over the icon, and the active tab's lift, dim, icon size and label all changed instantly while the balloon slid -- the balloon glided and its contents teleported. All four now share the balloon's duration and curve.
330 lines
14 KiB
Dart
330 lines
14 KiB
Dart
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<PluriNavItem> items;
|
|
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;
|
|
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,
|
|
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<double>(
|
|
duration: motion.normal,
|
|
curve: Curves.easeOutCubic,
|
|
tween: Tween<double>(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,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|