import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../tokens.dart'; import 'account_screen.dart'; import 'home_screen.dart'; import 'orders_screen.dart'; /// Bottom-navigation shell: Home · Orders · Account. /// /// Booking and tracking screens are pushed on top, so the tab bar only ever /// shows on the three destinations that need it. class ShellScreen extends StatefulWidget { const ShellScreen({super.key, this.initialTab = 0}); final int initialTab; static ShellScreenState? of(BuildContext context) => context.findAncestorStateOfType(); @override State createState() => ShellScreenState(); } class ShellScreenState extends State { late int _index = widget.initialTab; void goToTab(int index) { if (index == _index) return; HapticFeedback.selectionClick(); setState(() => _index = index); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: DmColors.canvas, // The bar floats, so it is stacked over the content rather than laid // out beneath it — and each root pads its own scroll view past it. extendBody: true, body: IndexedStack( index: _index, children: const [HomeScreen(), OrdersScreen(), AccountScreen()], ), bottomNavigationBar: _TabBar(index: _index, onChanged: goToTab), ); } } /// The tab bar, fixed to the bottom edge. /// /// ── A floating pill, after all ── /// /// This bar has been three things. It hovered as a pill, then sat on the edge /// with a hairline over it and a crimson indicator travelling along it, then /// lost the indicator's edge. The reference the app is now drawn against puts /// it back in the air — and the reason is better than the first time round. /// /// The pill works *because* the screens above it stopped having edges. When /// every card was outlined, a floating bar was a fourth rounded surface in a /// stack of them. Now the content is white shapes lifted off a near-white /// page, and the bar is one more of those: same radius language, same shadow, /// read as the same kind of object. /// /// The labels went with the edge. Three glyphs a customer has seen on every /// app they own do not need naming, and without them the bar is 62pt instead /// of 86 — which is the space the pill is floating in. class _TabBar extends StatelessWidget { const _TabBar({required this.index, required this.onChanged}); final int index; final ValueChanged onChanged; /// Lucide, not Material. One 1.5px stroke family at one weight, so the three /// glyphs read as a set instead of three different drawing styles — and the /// same set the pushed screens use. static const _items = [ (label: 'Home', icon: LucideIcons.house), (label: 'Orders', icon: LucideIcons.package), (label: 'Account', icon: LucideIcons.userRound), ]; static const double _slot = 74; static const double _height = 62; @override Widget build(BuildContext context) { return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), // A Row, not a Center: `bottomNavigationBar` hands its child an // unbounded height, and a Center in that slot expands to fill the // whole screen with the pill floating in the middle of it. child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( height: _height, padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( color: DmColors.surface, borderRadius: DmRadius.all(DmRadius.pill), boxShadow: DmShadow.cardRaised, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < _items.length; i++) SizedBox( width: _slot, child: _TabButton( label: _items[i].label, icon: _items[i].icon, selected: index == i, onTap: () => onChanged(i), ), ), ], ), ), ], ), ), ); } } class _TabButton extends StatefulWidget { const _TabButton({ required this.label, required this.icon, required this.selected, required this.onTap, }); final String label; final IconData icon; final bool selected; final VoidCallback onTap; @override State<_TabButton> createState() => _TabButtonState(); } class _TabButtonState extends State<_TabButton> { bool _down = false; @override Widget build(BuildContext context) { final selected = widget.selected; return Semantics( button: true, selected: selected, label: widget.label, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _down = true), onTapUp: (_) => setState(() => _down = false), onTapCancel: () => setState(() => _down = false), onTap: widget.onTap, child: AnimatedScale( scale: _down ? 0.88 : 1, duration: DmMotion.fast, curve: DmMotion.ease, child: Center( // ── Colour alone ── // // The glyph sat in a `brandSoft` rounded square, which made the // selected tab the only filled object in a bar whose whole job is // to recede. The reference marks it with the brand and nothing // else, and with three glyphs in a white pill that is unambiguous // — there is only one coloured thing on the bar. // // It arrives with a shade of overshoot, so the tab reacts to the // press rather than recolouring after it. child: AnimatedScale( scale: selected ? 1.08 : 1, duration: DmMotion.slow, curve: DmMotion.glide, child: AnimatedContainer( duration: DmMotion.base, curve: DmMotion.ease, child: Icon( widget.icon, size: 23, color: selected ? DmColors.brand : DmColors.ink4, ), ), ), ), ), ), ); } }