first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

565
lib/widget/Bottom_page.dart Normal file
View File

@@ -0,0 +1,565 @@
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,
),
),
),
),
),
),
],
),
),
),
),
);
}
}