Files
krow-worker-app/lib/widgets/krow_nav_bar.dart
2026-09-17 22:26:11 +05:30

216 lines
7.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/theme.dart';
class KrowNavItem {
const KrowNavItem({
required this.icon,
required this.label,
this.showDot = false,
});
final IconData icon;
final String label;
/// Marks the tab as needing attention, without saying how much.
final bool showDot;
}
/// Bottom navigation whose indicator slides between tabs.
///
/// Material's own [NavigationBar] fades its indicator in at the destination
/// and out at the origin, so the selection appears to teleport. Here the pill
/// travels, and the icons hand it over as it passes.
class KrowNavBar extends StatelessWidget {
const KrowNavBar({
super.key,
required this.items,
required this.currentIndex,
required this.onSelect,
});
final List<KrowNavItem> items;
final int currentIndex;
final ValueChanged<int> onSelect;
static const _pillWidth = 58.0;
static const _pillHeight = 32.0;
/// Breathing room between the bar's top edge and the icons. The pill and the
/// icons both start here, which is also what keeps them aligned — centring
/// the icons while pinning the pill to a fixed offset left the two a couple
/// of pixels apart.
static const _topPadding = 12.0;
/// Lets tests find the travelling indicator unambiguously.
static const indicatorKey = ValueKey('krow-nav-indicator');
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: KrowPalette.line)),
),
child: SafeArea(
top: false,
child: SizedBox(
height: 76,
child: LayoutBuilder(
builder: (context, constraints) {
final slot = constraints.maxWidth / items.length;
// The selected index animated as a double, so the pill's
// position and every icon's colour come from one value.
return TweenAnimationBuilder<double>(
tween: Tween(end: currentIndex.toDouble()),
duration: const Duration(milliseconds: 360),
curve: Curves.easeOutQuint,
builder: (context, position, _) {
final travel = (position - position.roundToDouble()).abs();
final stretch = 20 * (travel * 2);
return Stack(
children: [
Positioned(
left:
position * slot +
(slot - _pillWidth) / 2 -
stretch / 2,
top: _topPadding,
width: _pillWidth + stretch,
height: _pillHeight,
child: DecoratedBox(
key: indicatorKey,
decoration: BoxDecoration(
color: KrowPalette.blue,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: KrowPalette.blue.withValues(alpha: 0.28),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
),
),
Row(
children: [
for (var i = 0; i < items.length; i++)
Expanded(
child: _NavItem(
item: items[i],
// 1 when the pill is centred on this tab.
proximity: (1 - (position - i).abs()).clamp(
0.0,
1.0,
),
onTap: () {
if (i != currentIndex) {
HapticFeedback.selectionClick();
}
onSelect(i);
},
),
),
],
),
],
);
},
);
},
),
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.item,
required this.proximity,
required this.onTap,
});
final KrowNavItem item;
final double proximity;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// The icon only turns white once the pill is actually under it — a linear
// ramp would leave it half-white over bare white while the pill is still
// in transit. easeInCubic holds it grey, then flips on arrival.
final onPill = Curves.easeInCubic.transform(proximity);
final iconColor = Color.lerp(KrowPalette.mute, Colors.white, onPill)!;
// The label is never covered, so it can ramp gently the whole way.
final labelColor = Color.lerp(
KrowPalette.mute,
KrowPalette.blue,
Curves.easeIn.transform(proximity),
)!;
// Material's NavigationBar announces each tab and its selected state;
// rolling our own means saying so explicitly. container + excludeSemantics
// collapse the icon, dot and label into a single node — otherwise the
// annotation is split across the children and never reaches the reader.
return Semantics(
container: true,
excludeSemantics: true,
button: true,
selected: proximity > 0.5,
label: item.showDot ? '${item.label}, needs attention' : item.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
// Started from the top rather than centred, so the icon sits exactly
// where the pill is drawn.
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(height: KrowNavBar._topPadding),
SizedBox(
height: KrowNavBar._pillHeight,
child: Center(child: Icon(item.icon, size: 21, color: iconColor)),
),
const SizedBox(height: 3),
// The dot's slot is always reserved, so tabs with and without one
// keep their labels on the same line.
SizedBox(
height: 6,
child: Center(
child: AnimatedScale(
scale: item.showDot ? 1 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
),
),
),
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 11,
fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500,
color: labelColor,
),
),
],
),
),
);
}
}