Files
doormile_milderapp/lib/widget/Bottom_page.dart
2026-08-11 13:16:33 +05:30

566 lines
23 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:ui' show ImageFilter, lerpDouble;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/pickups/pickups.dart';
import 'package:miler/views/Dashboard/home/homepage.dart';
import 'package:miler/views/Dashboard/activity/activity_page.dart';
import 'package:miler/views/Dashboard/profile/Profilepage.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
class BottomPage extends StatefulWidget {
final int initialIndex;
final Widget? overridePage;
const BottomPage({super.key, this.initialIndex = 0, this.overridePage});
/// Cross-tab navigation request.
static final ValueNotifier<int?> tabRequest = ValueNotifier<int?>(null);
/// Switch the shell to [index]. 0 Home · 1 Bookings · 2 Activity · 3 Account.
static void goToTab(int index) => tabRequest.value = index;
/// The tab on screen right now.
///
/// Published because the shell keeps every tab **alive**: the pages live in
/// an `IndexedStack`, which builds all four at launch and only changes which
/// one is painted. A page's `initState` therefore runs once, before the rider
/// has done anything — so a tab that loads its data there shows whatever was
/// true at app start for the rest of the session. Activity hit exactly that:
/// the rider finished a stop, opened the tab, and read a result fetched
/// before he had finished anything.
///
/// A page that needs to be current listens to this and re-reads when its own
/// index arrives. Cheaper than rebuilding the tab from scratch, and it keeps
/// scroll position and state, which is why the IndexedStack is there.
static final ValueNotifier<int> currentTab = ValueNotifier<int>(0);
/// Height of the floating bar at rest, and the gap to the screen edge.
static const double navBarHeight = 66;
static const double navBarGap = 10;
/// Height of the bar once the rider has scrolled into a list.
///
/// ── Why it shrinks instead of leaving ──
///
/// It used to slide off the bottom of the screen entirely and come back when
/// he reversed. That buys 66pt of list, and costs him the ability to change
/// tab at all until he scrolls the other way — on a long Activity list, the
/// only route out is a gesture he has to know about. A control that vanishes
/// is not a smaller control; it is a missing one.
///
/// Compacting keeps every target on screen and still hands back most of the
/// room: labels go, the icons draw in, and the pill narrows. This is what
/// Instagram's iOS tab bar does, and it is why that bar never feels in the
/// way despite never going away.
static const double navBarHeightCompact = 46;
/// Room the bar claims at the foot of a tab page, system inset included.
///
/// Always the *resting* height, even while the bar is compact. The bar is a
/// `Positioned` pill inside the shell's `Stack`, not a `bottomNavigationBar`,
/// so the Scaffold reserves no room for it and `SafeArea` knows nothing about
/// it — anything a page pins to the bottom ends up underneath it otherwise.
///
/// Deliberately not the live height. Pages used to follow the bar up and down
/// through a `navVisible` notifier, which meant a pinned banner re-laid itself
/// out every time the rider changed scroll direction. Reserving the larger of
/// the two costs 20pt of gap while compact and buys a layout that never moves.
static double bottomInset(BuildContext context) =>
MediaQuery.of(context).padding.bottom + navBarGap.h + navBarHeight.h;
@override
State<BottomPage> createState() => _BottomPageState();
}
class _BottomPageState extends State<BottomPage> with TickerProviderStateMixin {
late int selected;
late final List<Widget> _pages;
late final AnimationController _tabAnim;
late final Animation<double> _tabCurve;
/// Drives the selection pill from the tab it was on to the tab just tapped.
///
/// The pill used to be part of each tab — an `AnimatedContainer` whose fill
/// went from transparent to red — so going Account → Home faded one pill out
/// on the right while a second faded in on the left. Nothing moved, which is
/// why it appeared "suddenly": there was never anything to cross Bookings and
/// Activity with.
///
/// There is now exactly one pill, owned by the bar, and this is its position
/// in tab units (2.0 is dead centre of Activity, 2.5 is between Activity and
/// Account). It is the single source of truth for the tint on every icon and
/// label too, so a tab lights up as the pill passes over it rather than
/// switching colour the instant it is tapped and waiting for the pill to
/// catch up.
/// Initialised *here* rather than in `initState`, deliberately.
///
/// A `late final` assigned in `initState` is never filled in on a `State` that
/// already exists, and hot reload reuses the existing `State` — it does not
/// re-run `initState`. So the reloaded `build` read a controller this instance
/// had never been given and threw `LateInitializationError: Field '_pillAnim'
/// has not been initialized`, taking the whole app down to a red screen until
/// a full restart.
///
/// A `late` field initialiser runs on first *access* instead, which happens
/// during that first post-reload build, so the field is simply there. `this`
/// is available because the initialiser is lazy — a plain (non-late) field
/// initialiser could not reach `vsync: this`.
late final AnimationController _pillAnim = AnimationController(
vsync: this,
// Slower than the 280ms the fading pills used. That duration was chosen for
// a cross-fade in place; a pill that has to physically cross two tabs needs
// long enough for the eye to follow it, or the travel is technically there
// and reads as a jump anyway.
duration: const Duration(milliseconds: 380),
value: 1.0,
);
/// Symmetric easing: it accelerates away from the old tab and settles into the
/// new one. `easeOutCubic` — right for something appearing — starts at full
/// speed, which on a sliding object looks like it was already moving before
/// the tap.
late final Animation<double> _pillCurve = CurvedAnimation(
parent: _pillAnim,
curve: Curves.easeInOutCubic,
);
late Tween<double> _pillTween = Tween<double>(
begin: selected.toDouble(),
end: selected.toDouble(),
);
double get _pillPos => _pillTween.evaluate(_pillCurve);
/// 0 at rest, 1 fully compact. Everything the bar changes as it shrinks —
/// height, corner radius, side inset, icon size, label opacity — is read off
/// this one value, so the parts cannot drift out of step with each other.
late final AnimationController _compactAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
);
late final Animation<double> _compact = CurvedAnimation(
parent: _compactAnim,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeOutCubic,
);
bool _isCompact = false;
void _setCompact(bool compact) {
if (_isCompact == compact) return;
_isCompact = compact;
compact ? _compactAnim.forward() : _compactAnim.reverse();
}
double _scrollAccum = 0;
static const double _compactAfter = 24;
static const double _expandAfter = 6;
static const _navItems = [
_NavItem(Icons.home_rounded, Icons.home_outlined, 'Home'),
_NavItem(
Icons.local_shipping_rounded,
Icons.local_shipping_outlined,
'Bookings',
),
// Was Earnings. A pay figure is a once-or-twice-a-day read and it did not
// need a permanent quarter of the bar — it opens from Account now. What the
// rider glances at between stops is what he has already finished, which
// used to sit on Home clogging the list he decides from.
_NavItem(Icons.history_rounded, Icons.history_outlined, 'Activity'),
_NavItem(Icons.person_rounded, Icons.person_outline, 'Account'),
];
@override
void initState() {
super.initState();
selected = widget.initialIndex;
BottomPage.currentTab.value = widget.initialIndex;
_pages = [
widget.overridePage ?? const Homepage(),
const MyPickups(),
const ActivityPage(),
const ProfilePage(),
];
_tabAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
value: 1.0,
);
_tabCurve = CurvedAnimation(parent: _tabAnim, curve: Curves.easeOutCubic);
BottomPage.tabRequest.addListener(_onTabRequested);
}
void _onTabRequested() {
final requested = BottomPage.tabRequest.value;
if (requested == null || !mounted) return;
BottomPage.tabRequest.value = null;
if (requested >= 0 && requested < _pages.length) _selectTab(requested);
}
void _selectTab(int i) {
_scrollAccum = 0;
_setCompact(false);
if (i == selected) return;
HapticFeedback.lightImpact();
// Start from where the pill actually *is*, not from the tab it was last
// headed to. Tapping again mid-slide otherwise teleports it back to the
// previous tab and re-runs the whole journey.
_pillTween = Tween<double>(begin: _pillPos, end: i.toDouble());
_pillAnim.forward(from: 0.0);
setState(() => selected = i);
BottomPage.currentTab.value = i;
_tabAnim.forward(from: 0.0);
}
@override
void dispose() {
BottomPage.tabRequest.removeListener(_onTabRequested);
_tabAnim.dispose();
_pillAnim.dispose();
_compactAnim.dispose();
super.dispose();
}
bool _onScroll(ScrollNotification n) {
if (n.metrics.axis != Axis.vertical) return false;
// A page too short to scroll can never compact the bar — otherwise a rider
// on a short screen ends up with the small version for no reason.
if (n.metrics.maxScrollExtent <= 0) {
_scrollAccum = 0;
_setCompact(false);
return false;
}
if (n is! ScrollUpdateNotification) return false;
final delta = n.scrollDelta ?? 0;
if (delta == 0) return false;
_scrollAccum = (_scrollAccum * delta > 0) ? _scrollAccum + delta : delta;
if (delta > 0 && !_isCompact && _scrollAccum > _compactAfter) {
_scrollAccum = 0;
_setCompact(true);
} else if (delta < 0 && _isCompact && _scrollAccum < -_expandAfter) {
_scrollAccum = 0;
_setCompact(false);
}
return false;
}
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
textTheme: Theme.of(
context,
).textTheme.apply(fontFamily: FontConstants.fontFamily),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: ColorConstants.surface,
extendBody: true,
body: NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: Stack(
children: [
// Page content
Positioned.fill(
child: FadeTransition(
opacity: _tabCurve,
child: AnimatedBuilder(
animation: _tabCurve,
builder: (_, child) => Transform.translate(
offset: Offset(0, 8 * (1 - _tabCurve.value)),
child: child,
),
child: IndexedStack(index: selected, children: _pages),
),
),
),
// ── Floating frosted bar, compacting rather than leaving ──
//
// Every dimension is lerped off one animation value, so the
// height, the radius, the inset and the contents shrink together
// instead of each running its own clock.
AnimatedBuilder(
animation: Listenable.merge([_compact, _pillAnim]),
builder: (context, _) {
final k = _compact.value;
final side = lerpDouble(16.w, 30.w, k)!;
final height = lerpDouble(
BottomPage.navBarHeight.h,
BottomPage.navBarHeightCompact.h,
k,
)!;
final radius = lerpDouble(32.r, 23.r, k)!;
final pos = _pillPos;
return Positioned(
left: side,
right: side,
bottom:
MediaQuery.of(context).padding.bottom +
BottomPage.navBarGap.h,
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(
alpha: 0.82,
),
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: Colors.black.withValues(alpha: 0.06),
width: 1.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.07),
blurRadius: 28,
offset: const Offset(0, 8),
),
],
),
child: SizedBox(
height: height,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 6.w,
vertical: lerpDouble(6.h, 4.h, k)!,
),
child: LayoutBuilder(
builder: (context, box) {
// Every tab gets an equal slot, matching the
// `Expanded` row below it, so the pill lands
// exactly under an icon at whole values of
// `pos`.
final slot = box.maxWidth / _navItems.length;
return Stack(
children: [
PositionedDirectional(
start: slot * pos,
top: 0,
bottom: 0,
width: slot,
child: Container(
margin: EdgeInsets.symmetric(
horizontal: 3.w,
),
decoration: BoxDecoration(
// Brand, to agree with the glyph
// it sits behind — see the tint in
// [_NavBarItem].
color: ColorConstants.primaryWash,
borderRadius: BorderRadius.circular(
lerpDouble(34.r, 20.r, k)!,
),
),
),
),
Row(
children: List.generate(
_navItems.length,
(i) {
// How lit this tab is: 1 with the
// pill on it, 0 once the pill is a
// whole tab away. The tabs being
// crossed therefore warm up and cool
// down as it passes, which is what
// sells the movement.
final t = (1.0 - (pos - i).abs())
.clamp(0.0, 1.0);
return Expanded(
child: _NavBarItem(
item: _navItems[i],
t: t,
compact: k,
onTap: () => _selectTab(i),
),
);
},
),
),
],
);
},
),
),
),
),
),
),
);
},
),
],
),
),
),
);
}
}
class _NavItem {
final IconData activeIcon;
final IconData inactiveIcon;
final String label;
const _NavItem(this.activeIcon, this.inactiveIcon, this.label);
}
class _NavBarItem extends StatelessWidget {
final _NavItem item;
/// How lit this tab is, 0 → 1, driven by where the selection pill currently
/// is rather than by which tab is selected.
///
/// This is the whole point of the redesign. With a boolean the icon flipped
/// red the instant it was tapped while the pill was still two tabs away —
/// destination and vehicle arriving at different times. Tying the tint to the
/// pill's distance instead means Bookings and Activity glow briefly as it
/// crosses them and fade again behind it.
final double t;
/// 0 at rest, 1 fully compact. The label collapses and the icon draws in as
/// this rises — see [BottomPage.navBarHeightCompact].
final double compact;
final VoidCallback onTap;
const _NavBarItem({
required this.item,
required this.t,
required this.compact,
required this.onTap,
});
@override
Widget build(BuildContext context) {
// ── The selected tab wears the brand ──
//
// This was [ColorConstants.emphasis], the neutral slate, on the rule that
// colour marks meaning and neutral marks state. The brand mark is not on
// any tab now that Home carries the rider's own avatar, so the nav bar is
// where Doormile's red belongs: it is the one piece of chrome on screen at
// all times, and the selected tab is the one thing on it worth colouring.
//
// #960019 reads at 9.4:1 on the bar's near-white frost, so it is legible
// outdoors — which is the only reason the neutral was ever safer.
final Color activeColor = ColorConstants.primary;
final Color inactiveColor = ColorConstants.onSurfaceVariant;
// Eased rather than linear: a straight ramp makes every tab the pill passes
// over look half-selected, so the bar reads as having no clear selection
// mid-slide. Cubed keeps the glow tight around the pill.
final lit = t * t * t;
final tint = Color.lerp(inactiveColor, activeColor, lit)!;
// The app's primary navigation was four unlabelled `InkWell`s: a screen
// reader announced "Home", "Bookings", "Activity", "Account" as loose text
// with no role and no indication of which one the rider was on. `selected`
// is the part that matters — without it he cannot tell where he is.
return Semantics(
button: true,
selected: lit > 0.5,
label: item.label,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
// Transparent, not tinted: the pill behind the row is the fill now.
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 4.w),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Transform.scale(
scale: (1.0 + 0.08 * lit) * (1 - 0.06 * compact),
// Filled and outline glyph stacked and cross-faded on the same
// value, so the icon fills in as the pill arrives. An
// `AnimatedSwitcher` keyed off a bool ran on its own 220ms clock
// and finished well before the pill did.
child: Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: 1 - lit,
child: Icon(
item.inactiveIcon,
color: tint,
size: 21.sp,
),
),
Opacity(
opacity: lit,
child: Icon(item.activeIcon, color: tint, size: 21.sp),
),
],
),
),
// ── The label collapses, it does not just fade ──
//
// Fading alone would leave its height behind, so the bar could
// not actually get shorter — it would shrink around a blank
// line. `Align(heightFactor:)` inside a `ClipRect` hands the
// room back as the text goes.
//
// The opacity runs ahead of the height (×1.8) so the label is
// gone before the line has finished closing, rather than being
// squashed on the way out.
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: (1 - compact).clamp(0.0, 1.0),
child: Opacity(
opacity: (1 - compact * 1.8).clamp(0.0, 1.0),
child: Padding(
padding: EdgeInsets.only(top: 2.h),
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: tint,
fontSize: 10.5.sp,
fontWeight: FontWeight.lerp(
FontWeight.w500,
FontWeight.w700,
lit,
),
fontFamily: FontConstants.fontFamily,
letterSpacing: -0.1,
),
),
),
),
),
),
],
),
),
),
),
);
}
}