Files
doormile_milderapp/lib/views/Dashboard/home/home_header.dart
2026-09-09 12:55:23 +05:30

617 lines
26 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 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE HOME HEADER — identity, duty, and the trip being worked
///
/// ── What was wrong with the last one ──
///
/// It was a fixed block above the scroll view — the Column's first child — and
/// it never went away. Everything it carried was therefore charged at every
/// scroll position, and it carried a lot: a 44pt avatar, an eyebrow reading
/// DOORMILE DELIVERY, "Hi, Rajan", a 34pt two-word duty switch, and the trip
/// selector inside a translucent rounded track of its own. Five objects
/// competing, none of them the work.
///
/// Two of them were also answering questions the app answers elsewhere. The
/// avatar duplicated the Account tab in the bottom bar. The trip's `33 of 50
/// done` was printed again, larger, in the run card immediately below it.
///
/// The cost was the only thing that mattered: on a 390×844 phone the rider's
/// first actual stop began below the fold.
///
/// ── What this is ──
///
/// One pinned sliver with two states and a short cross-fade between them.
///
/// ```
/// EXPANDED (~166dp of content, plus the status bar)
/// ┌──────────────────────────────────────────────────┐
/// │ DOORMILE ● ON │
/// │ │
/// │ Good afternoon, Rajan │
/// │ 17 stops remaining │
/// │ │
/// │ TRIP 1 TRIP 2 TRIP 3 │
/// │ 33 / 50 Not set Not set │
/// │ ━━━━━━━ │
/// └──────────────────────────────────────────────────┘
///
/// COLLAPSED (52dp of content, plus the status bar)
/// ┌──────────────────────────────────────────────────┐
/// │ Trip 1 · 17 left · 33/50 ● ON │
/// └──────────────────────────────────────────────────┘
/// ```
///
/// The greeting and the wordmark are the decoration, so they are what goes.
/// What survives the collapse is what a rider glancing at a mounted phone
/// needs: which trip, how much is left, and whether he is on duty.
///
/// ── Why a `SliverPersistentHeader` and not a `SliverAppBar` ──
///
/// `SliverAppBar` gives a `FlexibleSpaceBar`, which interpolates *one* piece of
/// content between two positions and scales it. This header does not scale
/// anything: it has two different layouts and swaps one for the other. A
/// persistent-header delegate hands over `shrinkOffset` directly, which is
/// exactly the input a cross-fade needs, and it is native — no package was
/// added for any of this.
/// ─────────────────────────────────────────────────────────────────────────
class HomeHeaderDelegate extends SliverPersistentHeaderDelegate {
/// The status-bar inset. Passed in rather than read from a `MediaQuery`,
/// because [minExtent] and [maxExtent] are called without a context.
final double topInset;
/// `Good afternoon, Rajan` — composed by the page, which owns the clock.
final String greeting;
/// `17 stops remaining`, or empty when there is no run to count.
///
/// **Not drawn in the expanded state** — see [_ExpandedBody]. It survives
/// because the page folds it into [collapsedSummary] for the collapsed line,
/// where the brief card is no longer on screen to carry the figure.
final String subtitle;
final bool onDuty;
/// Duty cannot be changed mid-pickup. The control dims and locks rather than
/// disappearing, so the state stays readable.
final bool dutyLocked;
/// A duty write is in flight. The control shows it and refuses a second
/// press; the page's own confirmation flow is untouched.
final bool dutyBusy;
final VoidCallback onToggleDuty;
/// The collapsed line's operational summary — `Trip 1 · 17 left · 33/50`.
/// Composed by the page from the same figures the selector draws, so the two
/// states cannot disagree.
final String collapsedSummary;
/// The expanded content's height for this context's text scale — see
/// [expandedContentFor]. Passed in for the same reason as [topInset]: the
/// extent getters have no context to measure with.
final double expandedContent;
const HomeHeaderDelegate({
required this.topInset,
required this.expandedContent,
required this.greeting,
required this.subtitle,
required this.onDuty,
required this.dutyLocked,
required this.dutyBusy,
required this.onToggleDuty,
required this.collapsedSummary,
});
/// The collapsed row's own height, without the status bar.
static double get collapsedContent => 52.h;
/// The header's own surface. Keyed so a test can measure *this* rather than
/// the `Scaffold`'s Material, which is the whole screen.
static const Key surfaceKey = ValueKey('home_header_surface');
/// The expanded content's height, without the status bar.
///
/// ── Why this is measured rather than hardcoded ──
///
/// A sliver header must declare a fixed extent, so anything short of the
/// content is a silent clip. The greeting is type that grows with the system
/// font, so the extent has to grow with it or a rider at 1.5× loses the
/// bottom of the header.
///
/// The `N stops remaining` line was charged here too until it was removed as
/// a duplicate of the brief card's own headline — which is why this is 20-odd
/// points cheaper than it was, and why the budget test moved with it.
///
/// Scaling is clamped at 1.3× inside the header (see [build]) for the same
/// reason the run card clamps it: these are figures and labels read at a
/// glance, not prose, and an unclamped 2.0× turns a 166pt header into a
/// 240pt one that pushes the work off the screen entirely — which is the
/// problem this redesign exists to fix.
static double expandedContentFor(BuildContext context) {
final scale = MediaQuery.textScalerOf(context).scale(1).clamp(1.0, 1.3);
// ── Ceiled, because text does not lay out in fractions of a pixel ──
//
// `fontSize * height` is the line box in theory; the engine rounds font
// metrics, so the greeting measures up to a pixel taller than the
// arithmetic says. That slack used to be absorbed by the 4pt gap above the
// `stops remaining` line, and removing that line exposed it — a header
// short by 0.12 to 0.4 of a pixel, which is a `RenderFlex overflowed`
// stripe across Home for a rounding error.
//
// Ceiling the term is the fix rather than adding a fudge constant: it is
// exactly the quantity being rounded, and it cannot drift the way a magic
// `+2` bolted on the end would.
return topPad +
wordmarkRow +
2.h +
(24.sp * 1.15 * scale).ceilToDouble() + // greeting
bottomPad;
}
/// The three metrics [_ExpandedBody] lays out against, named here so the
/// measurement above and the layout below cannot drift apart — a sliver
/// header whose extent disagrees with its content is a silent clip.
static double get topPad => 6.h;
/// The wordmark and duty row.
///
/// [DutyPill.tapTarget], not the pill's own ~30pt: the row holds the one
/// control up here, and a 48pt target inside a 28pt row is a 28pt target.
/// The eight points this costs came off the paddings either side of it.
static double get wordmarkRow => DutyPill.tapTarget;
static double get bottomPad => 20.h;
/// Everything the header draws, so the page can measure the fold.
static double heightFor(BuildContext context) =>
MediaQuery.paddingOf(context).top + expandedContentFor(context);
@override
double get minExtent => topInset + collapsedContent;
@override
double get maxExtent => topInset + expandedContent;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlaps) {
final range = (maxExtent - minExtent).clamp(1.0, double.infinity);
// 0 fully expanded → 1 fully collapsed.
final t = (shrinkOffset / range).clamp(0.0, 1.0);
// ── Two fades that do not overlap in the middle ──
//
// Crossing them linearly leaves both layouts half-visible for most of the
// travel, which reads as the header being broken rather than as it
// changing. The expanded content is gone by 60% of the journey and the
// collapsed line does not start until 55%, so there is a beat where the bar
// is just brand — and that beat is what makes the swap read as deliberate.
final expandedOpacity = (1 - t / 0.6).clamp(0.0, 1.0);
final collapsedOpacity = ((t - 0.55) / 0.45).clamp(0.0, 1.0);
return Material(
key: surfaceKey,
// ── A gradient, and it is nearly not one ──
//
// Two stops about eight per cent apart, top-left to bottom-right. Enough
// that the header reads as a surface with light falling across it rather
// than as a rectangle filled with one hex value; not enough to be seen as
// a gradient, which on a brand ground is the difference between premium
// and 2014. The brand red stays the darker stop, so nothing about the
// colour the app is known by changes.
type: MaterialType.transparency,
// ── The corner, and the case for evaluating it ──
//
// A 20pt bottom radius reads well at rest: the header is an object
// sitting on the page rather than a band painted across the top of it.
// Pinned over scrolling content it reads as a mistake — a rounded lid
// with rows sliding under its corners.
//
// So it is a rounded card when it is at rest and a straight bar when it
// is holding the top of the screen, interpolated on the same `t` as
// everything else. Straight was the alternative considered and it is
// cleaner while scrolling and flatter at rest; this keeps both.
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(20.r * (1 - t)),
),
clipBehavior: Clip.antiAlias,
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [ColorConstants.brandBarTop, ColorConstants.brandBarBottom],
),
),
child: MediaQuery.withClampedTextScaling(
maxScaleFactor: 1.3,
child: Padding(
padding: EdgeInsets.only(top: topInset),
child: Stack(
fit: StackFit.expand,
children: [
if (collapsedOpacity > 0)
Align(
alignment: Alignment.topCenter,
child: Opacity(
opacity: collapsedOpacity,
child: SizedBox(
height: collapsedContent,
child: _CollapsedRow(
summary: collapsedSummary,
onDuty: onDuty,
locked: dutyLocked,
busy: dutyBusy,
onTap: onToggleDuty,
),
),
),
),
if (expandedOpacity > 0)
// ── Laid out at its full height, then clipped ──
//
// This was a plain child of a `StackFit.expand` stack, so it
// was given the header's *current* height — and the header's
// current height is shrinking. The greeting and its subtitle
// are a Column of fixed rows that cannot compress, so from
// the first pixel of scroll the Column was handed less than
// it needs and Flutter drew the yellow-and-black
// `BOTTOM OVERFLOWED BY 28 PIXELS` banner across the header.
//
// The fade already handles the disappearing. What this
// content must do while it fades is *hold its shape and slide
// out from under the top of the screen*, which is what a
// collapsing header looks like — not reflow itself into a
// smaller and smaller box.
//
// `Positioned` with an explicit height opts out of
// `StackFit.expand`: the body is measured at
// [expandedContent] whatever the header currently is, pinned
// to the top, and the overflow is clipped by the Stack (and
// rounded off by the Material above it) instead of being
// reported as a layout error.
Positioned(
top: 0,
left: 0,
right: 0,
height: expandedContent,
// Ignored once it is mostly faded, so a half-visible
// greeting cannot swallow a tap meant for the collapsed
// control under it.
child: IgnorePointer(
ignoring: expandedOpacity < 0.5,
child: Opacity(
opacity: expandedOpacity,
child: _ExpandedBody(
greeting: greeting,
onDuty: onDuty,
dutyLocked: dutyLocked,
dutyBusy: dutyBusy,
onToggleDuty: onToggleDuty,
),
),
),
),
],
),
),
),
),
);
}
/// ── The rebuild gate ──
///
/// The whole point of listing every field: a row further down the trip
/// changing does not touch any of these, so the header does not rebuild for
/// it. The header carries no collections at all now that the trip selector
/// has moved out from under it, so every comparison here is a scalar.
@override
bool shouldRebuild(HomeHeaderDelegate old) =>
old.topInset != topInset ||
old.expandedContent != expandedContent ||
old.greeting != greeting ||
old.subtitle != subtitle ||
old.onDuty != onDuty ||
old.dutyLocked != dutyLocked ||
old.dutyBusy != dutyBusy ||
old.collapsedSummary != collapsedSummary;
}
/// The expanded layout: who, whether he is working, and which trip.
class _ExpandedBody extends StatelessWidget {
final String greeting;
final bool onDuty;
final bool dutyLocked;
final bool dutyBusy;
final VoidCallback onToggleDuty;
const _ExpandedBody({
required this.greeting,
required this.onDuty,
required this.dutyLocked,
required this.dutyBusy,
required this.onToggleDuty,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
20.w,
HomeHeaderDelegate.topPad,
16.w,
HomeHeaderDelegate.bottomPad,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: HomeHeaderDelegate.wordmarkRow,
child: Row(
children: [
// ── The mark, at label size ──
//
// It was `DOORMILE DELIVERY` under a 44pt avatar. The rider
// knows which app he opened; what the wordmark is for is
// telling him whose *shift* this is on a phone that may carry
// two of these. One word, set as a label, is enough for that.
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'DOORMILE',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w900,
letterSpacing: 1.6,
color: Colors.white.withValues(alpha: 0.72),
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(width: 12.w),
DutyPill(
onDuty: onDuty,
locked: dutyLocked,
busy: dutyBusy,
onTap: onToggleDuty,
),
],
),
),
// Two, not ten. The row above is 48 tall to hold a 48pt tap target
// around a 30pt pill, so it already carries ~17pt of air under the
// wordmark — and a gap stacked on air is ten points of the rider's
// first stop.
SizedBox(height: 2.h),
Text(
greeting,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 24.sp,
height: 1.15,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
// ── The `N stops remaining` line used to sit here ──
//
// It was justified as "the one operational line above the fold". It
// stopped being the only one: the brief card immediately below leads
// with `20 stops left` as its headline, at nearly twice the size and
// against a progress bar. So the figure was on screen twice, forty
// points apart, the smaller and greyer of the two first.
//
// The header's job is identity and duty. The count belongs to the
// card that also shows what it is made of — see [TripBriefStrip].
//
// `subtitle` is kept on the delegate because the page still folds it
// into [collapsedSummary], which is the *only* place that figure
// appears once the header shrinks and the brief has scrolled away.
],
),
);
}
}
/// The collapsed layout: the operational context, and nothing else.
class _CollapsedRow extends StatelessWidget {
final String summary;
final bool onDuty;
final bool locked;
final bool busy;
final VoidCallback onTap;
const _CollapsedRow({
required this.summary,
required this.onDuty,
required this.locked,
required this.busy,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Row(
children: [
Expanded(
// Scale-down rather than ellipsis. This line is `Trip 1 · 17 left ·
// 33/50` — three figures — and an ellipsis takes the last one off,
// which is the one a rider is scrolling past the header to check.
// On a 320pt phone it shrinks instead.
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
summary,
maxLines: 1,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
SizedBox(width: 12.w),
DutyPill(onDuty: onDuty, locked: locked, busy: busy, onTap: onTap),
],
),
);
}
}
/// ── Duty, as a status rather than as a setting ──
///
/// It was `AnimatedToggleSwitch.dual`: a 34pt two-position track carrying the
/// words *On duty* and *Off duty* and a sliding knob. That is a settings
/// control — the shape a preferences screen uses — and it was the second
/// loudest object in a header whose subject is the work.
///
/// What a rider needs from it is an answer, not an affordance: **am I on
/// duty?** So it states the answer, in the smallest form that can carry it, and
/// the tap is how you change it. `● ON` reads at arm's length; the dot is
/// green, which is this palette's only positive-state colour and is spent
/// nowhere else in the header.
///
/// **Off is not red.** Off duty is not an error, and red on maroon is
/// invisible anyway — it is the same white pill with the dot unlit.
///
/// The control is visibly ~30pt tall and its target is 48. Nothing about the
/// duty *logic* changed: this calls the page's existing confirm-then-write
/// flow, and refuses a press while one is in flight or while a pickup is live.
class DutyPill extends StatelessWidget {
final bool onDuty;
final bool locked;
final bool busy;
final VoidCallback onTap;
const DutyPill({
super.key,
required this.onDuty,
required this.locked,
required this.busy,
required this.onTap,
});
/// The accessibility floor, which the visible pill is deliberately under.
static const double tapTarget = 48.0;
@override
Widget build(BuildContext context) {
final enabled = !locked && !busy;
return Semantics(
button: true,
enabled: enabled,
toggled: onDuty,
label: locked
? 'On duty, locked while a pickup is running'
: busy
? 'Changing duty status'
: onDuty
? 'On duty. Tap to go off duty.'
: 'Off duty. Tap to go on duty.',
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled ? onTap : null,
// The target is the box; the pill inside it is what is drawn. A 30pt
// control with a 30pt target is a control a moving thumb misses.
child: SizedBox(
height: tapTarget,
child: Center(
child: AnimatedOpacity(
duration: DesignConstants.motionState,
opacity: enabled ? 1 : 0.55,
child: Container(
height: 30.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: onDuty ? 0.18 : 0.11),
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
border: Border.all(
color: Colors.white.withValues(alpha: onDuty ? 0.5 : 0.28),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (busy)
SizedBox(
width: 10.w,
height: 10.w,
child: const CircularProgressIndicator(
strokeWidth: 1.8,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
else if (locked)
Icon(LucideIcons.lock, size: 11.sp, color: Colors.white)
else
AnimatedContainer(
duration: DesignConstants.motionState,
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: onDuty
? ColorConstants.acceptGreen
: Colors.white.withValues(alpha: 0.45),
boxShadow: onDuty
? [
BoxShadow(
color: ColorConstants.acceptGreen
.withValues(alpha: 0.55),
blurRadius: 6,
),
]
: null,
),
),
SizedBox(width: 7.w),
Text(
onDuty ? 'ON' : 'OFF',
maxLines: 1,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w900,
letterSpacing: 0.8,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
),
);
}
}