617 lines
26 KiB
Dart
617 lines
26 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|