production
This commit is contained in:
616
lib/views/Dashboard/home/home_header.dart
Normal file
616
lib/views/Dashboard/home/home_header.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user