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:
@@ -14,6 +14,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
required this.listSurface,
|
required this.listSurface,
|
||||||
required this.liveGreen,
|
required this.liveGreen,
|
||||||
required this.offlineAccent,
|
required this.offlineAccent,
|
||||||
|
required this.balloonSurface,
|
||||||
required this.radiusSm,
|
required this.radiusSm,
|
||||||
required this.radiusMd,
|
required this.radiusMd,
|
||||||
required this.radiusLg,
|
required this.radiusLg,
|
||||||
@@ -46,6 +47,12 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
/// promote, but the hex value itself is not this file's discretion.
|
/// promote, but the hex value itself is not this file's discretion.
|
||||||
final Color offlineAccent;
|
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 radiusSm;
|
||||||
final double radiusMd;
|
final double radiusMd;
|
||||||
final double radiusLg;
|
final double radiusLg;
|
||||||
@@ -75,6 +82,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
listSurface: Color(0xFF102532),
|
listSurface: Color(0xFF102532),
|
||||||
liveGreen: Color(0xFF7EE4C2),
|
liveGreen: Color(0xFF7EE4C2),
|
||||||
offlineAccent: Color(0xFFE8879A),
|
offlineAccent: Color(0xFFE8879A),
|
||||||
|
balloonSurface: Color(0xFF0A1B24),
|
||||||
radiusSm: 14,
|
radiusSm: 14,
|
||||||
radiusMd: 22,
|
radiusMd: 22,
|
||||||
radiusLg: 30,
|
radiusLg: 30,
|
||||||
@@ -95,6 +103,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
Color? listSurface,
|
Color? listSurface,
|
||||||
Color? liveGreen,
|
Color? liveGreen,
|
||||||
Color? offlineAccent,
|
Color? offlineAccent,
|
||||||
|
Color? balloonSurface,
|
||||||
double? radiusSm,
|
double? radiusSm,
|
||||||
double? radiusMd,
|
double? radiusMd,
|
||||||
double? radiusLg,
|
double? radiusLg,
|
||||||
@@ -113,6 +122,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
listSurface: listSurface ?? this.listSurface,
|
listSurface: listSurface ?? this.listSurface,
|
||||||
liveGreen: liveGreen ?? this.liveGreen,
|
liveGreen: liveGreen ?? this.liveGreen,
|
||||||
offlineAccent: offlineAccent ?? this.offlineAccent,
|
offlineAccent: offlineAccent ?? this.offlineAccent,
|
||||||
|
balloonSurface: balloonSurface ?? this.balloonSurface,
|
||||||
radiusSm: radiusSm ?? this.radiusSm,
|
radiusSm: radiusSm ?? this.radiusSm,
|
||||||
radiusMd: radiusMd ?? this.radiusMd,
|
radiusMd: radiusMd ?? this.radiusMd,
|
||||||
radiusLg: radiusLg ?? this.radiusLg,
|
radiusLg: radiusLg ?? this.radiusLg,
|
||||||
@@ -143,6 +153,9 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
|||||||
liveGreen: Color.lerp(liveGreen, other.liveGreen, t) ?? liveGreen,
|
liveGreen: Color.lerp(liveGreen, other.liveGreen, t) ?? liveGreen,
|
||||||
offlineAccent:
|
offlineAccent:
|
||||||
Color.lerp(offlineAccent, other.offlineAccent, t) ?? offlineAccent,
|
Color.lerp(offlineAccent, other.offlineAccent, t) ?? offlineAccent,
|
||||||
|
balloonSurface:
|
||||||
|
Color.lerp(balloonSurface, other.balloonSurface, t) ??
|
||||||
|
balloonSurface,
|
||||||
radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm,
|
radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm,
|
||||||
radiusMd: lerpDouble(radiusMd, other.radiusMd, t) ?? radiusMd,
|
radiusMd: lerpDouble(radiusMd, other.radiusMd, t) ?? radiusMd,
|
||||||
radiusLg: lerpDouble(radiusLg, other.radiusLg, t) ?? radiusLg,
|
radiusLg: lerpDouble(radiusLg, other.radiusLg, t) ?? radiusLg,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
import 'pluri_glass_surface.dart';
|
|
||||||
import 'pluri_icon.dart';
|
import 'pluri_icon.dart';
|
||||||
|
|
||||||
class PluriNavItem {
|
class PluriNavItem {
|
||||||
@@ -11,6 +10,13 @@ class PluriNavItem {
|
|||||||
final String label;
|
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 {
|
class PluriBottomNavigation extends StatelessWidget {
|
||||||
const PluriBottomNavigation({
|
const PluriBottomNavigation({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -23,18 +29,143 @@ class PluriBottomNavigation extends StatelessWidget {
|
|||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.pluriTokens;
|
final t = context.pluriTokens;
|
||||||
return PluriGlassSurface(
|
final motion = context.pluriMotion;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 7),
|
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),
|
borderRadius: BorderRadius.circular(999),
|
||||||
glowColor: t.glowColor.withValues(alpha: 0.28),
|
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(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < items.length; i++)
|
for (var i = 0; i < items.length; i++)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: i == selectedIndex ? 15 : 10,
|
key: itemKey(i),
|
||||||
child: _PluriNavButton(
|
child: _PluriNavButton(
|
||||||
item: items[i],
|
item: items[i],
|
||||||
selected: i == selectedIndex,
|
selected: i == selectedIndex,
|
||||||
@@ -43,10 +174,32 @@ class PluriBottomNavigation extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
class _PluriNavButton extends StatelessWidget {
|
||||||
const _PluriNavButton({
|
const _PluriNavButton({
|
||||||
required this.item,
|
required this.item,
|
||||||
@@ -61,91 +214,82 @@ class _PluriNavButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.pluriTokens;
|
final t = context.pluriTokens;
|
||||||
final foreground = Theme.of(context).colorScheme.onSurface;
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
child: AnimatedContainer(
|
child: Material(
|
||||||
duration: context.pluriMotion.normal,
|
type: MaterialType.transparency,
|
||||||
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: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Align(
|
||||||
padding: EdgeInsets.symmetric(
|
alignment: Alignment.bottomCenter,
|
||||||
horizontal: selected ? 8 : 3,
|
// t4/4a spec: the items row itself is 52px tall — this inner
|
||||||
vertical: selected ? 8 : 7,
|
// 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(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
AnimatedScale(
|
Opacity(
|
||||||
scale: selected ? 1.16 : 0.96,
|
// t4/4a spec: active icon full colour; inactive
|
||||||
duration: context.pluriMotion.quick,
|
// `rgba(242,247,250,.46)` — .46 applied here as
|
||||||
curve: Curves.easeOutBack,
|
// 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(
|
child: PluriIcon(
|
||||||
glyph: item.glyph,
|
glyph: item.glyph,
|
||||||
variant:
|
variant: PluriIconVariant.filled,
|
||||||
selected
|
// t4/4a spec: icon `font-size:25px`/`23px`.
|
||||||
? PluriIconVariant.activeGlow
|
size: selected ? 25 : 23,
|
||||||
: PluriIconVariant.filled,
|
color: selected ? t.electricMagenta : null,
|
||||||
size: selected ? 42 : 34,
|
// 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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnimatedSize(
|
if (selected) ...[
|
||||||
duration: context.pluriMotion.quick,
|
const SizedBox(height: 3),
|
||||||
curve: Curves.easeOutCubic,
|
Text(
|
||||||
child:
|
|
||||||
selected
|
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 2),
|
|
||||||
child: Text(
|
|
||||||
item.label,
|
item.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
// t4/4a spec: label `font-size:11px;
|
||||||
|
// font-weight:800; line-height:1.25`, brand
|
||||||
|
// colour.
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.copyWith(
|
).textTheme.labelSmall?.copyWith(
|
||||||
color: foreground,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: -0.2,
|
height: 1.25,
|
||||||
|
letterSpacing: 0,
|
||||||
|
color: t.electricMagenta,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class PluriIcon extends StatelessWidget {
|
|||||||
this.variant = PluriIconVariant.outline,
|
this.variant = PluriIconVariant.outline,
|
||||||
this.size = 24,
|
this.size = 24,
|
||||||
this.semanticLabel,
|
this.semanticLabel,
|
||||||
|
this.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PluriIconGlyph glyph;
|
final PluriIconGlyph glyph;
|
||||||
@@ -22,6 +23,12 @@ class PluriIcon extends StatelessWidget {
|
|||||||
final double size;
|
final double size;
|
||||||
final String? semanticLabel;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = context.pluriTokens;
|
final tokens = context.pluriTokens;
|
||||||
@@ -82,6 +89,7 @@ class PluriIcon extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
|
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
|
||||||
|
if (color != null) return color!;
|
||||||
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
|
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
|
||||||
if (variant == PluriIconVariant.filled) {
|
if (variant == PluriIconVariant.filled) {
|
||||||
return Theme.of(context).colorScheme.onSurface;
|
return Theme.of(context).colorScheme.onSurface;
|
||||||
|
|||||||
@@ -2,13 +2,22 @@
|
|||||||
|
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
import 'mini_reproductor.dart';
|
import 'mini_reproductor.dart';
|
||||||
|
import 'pluri_bottom_navigation.dart';
|
||||||
|
|
||||||
abstract final class PluriLayout {
|
abstract final class PluriLayout {
|
||||||
static const double horizontal = 16;
|
static const double horizontal = 16;
|
||||||
static const double sectionGap = 12;
|
static const double sectionGap = 12;
|
||||||
static const double panelGap = 12;
|
static const double panelGap = 12;
|
||||||
static const double compactGap = 8;
|
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.
|
/// ADR-7(b): `bottomChromeInset` assumes `MiniReproductor` is visible.
|
||||||
/// Escuchar hides it (design's one exception — the embedded hero already
|
/// Escuchar hides it (design's one exception — the embedded hero already
|
||||||
|
|||||||
@@ -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<int>? 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<PluriIcon>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(PluriBottomNavigation.itemKey(2)),
|
||||||
|
matching: find.byType(PluriIcon),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(activeIcon.color, PluriWaveTokens.brand);
|
||||||
|
|
||||||
|
final label = tester.widget<Text>(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<PluriIcon>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(PluriBottomNavigation.itemKey(1)),
|
||||||
|
matching: find.byType(PluriIcon),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(inactiveIcon.color, isNull);
|
||||||
|
|
||||||
|
final dimmed = tester.widget<Opacity>(
|
||||||
|
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<DecoratedBox>(
|
||||||
|
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<EstadoRadio>.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',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user