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, body: IndexedStack( index: _index, children: const [HomeScreen(), OrdersScreen(), AccountScreen()], ), bottomNavigationBar: _TabBar(index: _index, onChanged: goToTab), ); } } /// The tab bar, fixed to the bottom edge. /// /// ── Why it is no longer a floating pill-shaped bar ── /// /// It used to hover 10pt above the bottom with a 28pt radius and a shadow /// under it, which put a second rounded surface between the content and the /// gesture bar and made every screen end in two stacked edges. It now sits on /// the edge with a single hairline above it, like the footers in the rest of /// the app. /// /// The indicator does the work instead: a crimson pill that travels to the tab /// you pick, with the glyph turning white as it arrives. Selection reads as one /// object moving rather than three tabs changing colour in place — and the /// label under it stays crimson, so the tab is still identifiable in the /// half-second the pill is in flight. /// /// ── What the 2026-09-22 pass changed ── /// /// **The pill stopped glowing.** It carried [DmShadow.liftSm] — a 65% crimson /// shadow built for the committing button on a screen — which on a bar resting /// against the bottom edge read as a smear under the tab rather than as lift. /// It is a quarter of that strength now and sits closer to the shape. /// /// **The glyph arrives.** The icon pops to 1.14 and settles as the pill lands /// under it, on the same overshoot curve the pill travels on, so the two read /// as one gesture instead of a moving rectangle and an icon that recolours when /// it happens to be covered. /// /// **A tab gives way when pressed.** The whole column scales to 0.9 under the /// finger. Without it the only feedback for a tap was the pill starting to /// move, which on the tab you are already on is no feedback at all. 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), ]; /// The travelling pill's size. Narrower than its slot so it reads as an /// object sitting in the row rather than as a selected cell. static const double _pillWidth = 62; static const double _pillHeight = 34; /// The pill's own lift. Not [DmShadow.liftSm]: that is the glow under a /// committing action, and at the bottom edge of the screen it read as dirt /// under the tab. static const _pillShadow = [ BoxShadow( color: Color(0x2E8F0F06), blurRadius: 10, spreadRadius: -2, offset: Offset(0, 4), ), ]; @override Widget build(BuildContext context) { return DecoratedBox( decoration: const BoxDecoration( color: DmColors.surface, border: Border(top: BorderSide(color: DmColors.border)), ), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(6, 9, 6, 7), child: SizedBox( // The row is the pill plus the label, and the label is type: at // 1.8x accessibility text a fixed 58 clipped it by two points. The // bar gets taller instead, which is what a larger-text user asked // for. height: _pillHeight + 5 + MediaQuery.textScalerOf(context).scale(11.5) * 1.3, child: LayoutBuilder( builder: (context, constraints) { final slot = constraints.maxWidth / _items.length; return Stack( children: [ AnimatedPositioned( duration: DmMotion.slow, curve: DmMotion.glide, left: slot * index + (slot - _pillWidth) / 2, width: _pillWidth, top: 0, height: _pillHeight, child: DecoratedBox( decoration: BoxDecoration( color: DmColors.brand, borderRadius: DmRadius.all(DmRadius.pill), boxShadow: _pillShadow, ), ), ), Row( children: [ for (var i = 0; i < _items.length; i++) Expanded( 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) { return Semantics( button: true, selected: widget.selected, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _down = true), onTapUp: (_) => setState(() => _down = false), onTapCancel: () => setState(() => _down = false), onTap: widget.onTap, child: AnimatedScale( // The whole column, including the label: a press that only moved the // glyph would read as the icon coming loose from its own tab. scale: _down ? 0.9 : 1, duration: DmMotion.fast, curve: DmMotion.ease, child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ SizedBox( height: _TabBar._pillHeight, child: Center( child: AnimatedScale( // Lands with the pill, on the pill's own curve. The // overshoot is what ties the two together — the glyph is // reacting to the thing arriving under it. scale: widget.selected ? 1.14 : 1, duration: DmMotion.slow, curve: DmMotion.glide, child: AnimatedSwitcher( duration: DmMotion.base, child: Icon( widget.icon, key: ValueKey(widget.selected), size: 21, color: widget.selected ? Colors.white : DmColors.ink3, ), ), ), ), ), const SizedBox(height: 5), AnimatedDefaultTextStyle( duration: DmMotion.base, curve: DmMotion.ease, style: DmText.label.copyWith( fontSize: 11.5, height: 1.0, color: widget.selected ? DmColors.brand : DmColors.ink3, fontWeight: widget.selected ? FontWeight.w700 : FontWeight.w600, ), child: Text( widget.label, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), ), ); } }