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( 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, ), ), ], ), ), ), ), ), ), ); } }