Initial commit: Krow Worker App
This commit is contained in:
215
lib/widgets/krow_nav_bar.dart
Normal file
215
lib/widgets/krow_nav_bar.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user