production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -0,0 +1,616 @@
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: [Color(0xFFA51C1C), Color(0xFF8B1A1A)],
),
),
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.w800,
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.w700,
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.w600,
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.w800,
letterSpacing: 0.8,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
),
);
}
}