Files
doormile_milderapp/lib/widget/Bottom_page.dart
2026-09-09 12:55:23 +05:30

603 lines
25 KiB
Dart
Raw 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:lucide_icons_flutter/lucide_icons.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/narrative.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/data/service_profile.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 bar at rest.
static const double navBarHeight = 66;
/// The gap the bar used to float above the screen's edge. Zero now: it is
/// docked, so there is nothing between it and the bottom of the phone. Kept
/// as a name because [bottomInset] and several pages read it, and a bar that
/// changes where it sits should not also change how many numbers describe it.
static const double navBarGap = 0;
/// 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;
/// ── The work tab is named by the line the rider is on ──
///
/// The two rider apps this one replaces called a job by two different names:
/// **booking** on the parcel side, **delivery** on the meal side. Merging them
/// did not make one of those wrong — a rider collecting a consignment for the
/// hub is not delivering it, and a rider handing somebody lunch has not taken
/// a booking. So the tab reads the noun off the profile instead of picking a
/// winner, and a rider (who only ever belongs to one line) sees the word he
/// already uses. See [ServiceProfile.jobNoun].
///
/// No longer `const`: the label is resolved per build, not baked in.
static List<_NavItem> get _navItems => [
const _NavItem(LucideIcons.house, LucideIcons.house, 'Home'),
_NavItem(
LucideIcons.truck,
LucideIcons.truck,
ServiceProfile.active.workTabLabel,
),
// 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.
const _NavItem(LucideIcons.history, LucideIcons.history, 'Activity'),
const _NavItem(LucideIcons.user, LucideIcons.user, '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),
),
),
),
// ── Docked, opaque, and full width ──
//
// It was a frosted pill floating 10 points off the bottom with a
// 16-point margin down each side. Two costs, and the second is
// the one that decided it.
//
// **It was not where a nav bar is.** Every other app on the
// rider's phone puts the tabs on the bottom edge; a pill hovering
// above it reads as a widget over the page rather than as the
// frame of the app, and the strip of page visible under it is a
// gap nothing lives in.
//
// **It was glass.** A 20-sigma backdrop blur, permanently on
// screen, over a scrolling list — the most expensive thing this
// app drew, on the one surface that is never not drawn.
//
// It still compacts rather than leaving: labels go, the icons
// draw in, the bar shortens. A control that vanishes is not a
// smaller control, it is a missing one.
AnimatedBuilder(
animation: Listenable.merge([_compact, _pillAnim]),
builder: (context, _) {
final k = _compact.value;
final side = 6.w;
final height = lerpDouble(
BottomPage.navBarHeight.h,
BottomPage.navBarHeightCompact.h,
k,
)!;
final radius = lerpDouble(26.r, 20.r, k)!;
final pos = _pillPos;
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(radius),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Narrative.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(radius),
),
border: Border(
top: BorderSide(
color: Narrative.cardBorder,
width: 1.0,
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 20,
offset: const Offset(0, -4),
),
],
),
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.w600,
FontWeight.w700,
lit,
),
fontFamily: FontConstants.fontFamily,
letterSpacing: -0.1,
),
),
),
),
),
),
],
),
),
),
),
);
}
}