Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,13 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import 'miler_app_bar.dart' show milerGlassSurface;
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
|
||||
import '../constants/Colorconstants.dart';
|
||||
@@ -114,11 +117,28 @@ class PrimaryButton extends StatelessWidget {
|
||||
height: height,
|
||||
child: ElevatedButton(
|
||||
onPressed: loading ? null : onPressed,
|
||||
// ── A blocked primary goes neutral, it does not go pale ──
|
||||
//
|
||||
// This drew the brand at 50% with its label at 80% white: still a
|
||||
// saturated pill, still shaped like the thing you press, just dimmer.
|
||||
// A rider reads that as the button and taps it, and nothing happens —
|
||||
// which is the exact failure `stop_verify_chrome_test` pins on the
|
||||
// other screen that has a blocked step, where the fix was to draw no
|
||||
// coloured button at all.
|
||||
//
|
||||
// Neutral fill with the page's secondary ink says *not yet* instead of
|
||||
// *try me*, and it clears AA at 5.45:1 where white-on-washed-red did
|
||||
// not come close. `loading` keeps the brand, because a button mid-work
|
||||
// is disabled for a different reason and must not look unavailable.
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: foreground,
|
||||
disabledBackgroundColor: color.withValues(alpha: 0.5),
|
||||
disabledForegroundColor: foreground.withValues(alpha: 0.8),
|
||||
disabledBackgroundColor: loading
|
||||
? color
|
||||
: ColorConstants.neutralLight,
|
||||
disabledForegroundColor: loading
|
||||
? foreground
|
||||
: ColorConstants.secondaryText,
|
||||
elevation: 0,
|
||||
minimumSize: Size(double.infinity, height),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -149,7 +169,7 @@ class PrimaryButton extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -212,7 +232,7 @@ class SecondaryButton extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: c,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -258,7 +278,7 @@ class StatusChip extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -328,7 +348,7 @@ class SectionHeader extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -344,7 +364,7 @@ class SectionHeader extends StatelessWidget {
|
||||
action!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -404,7 +424,7 @@ class InfoBanner extends StatelessWidget {
|
||||
const InfoBanner({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.icon = Icons.info_outline_rounded,
|
||||
this.icon = LucideIcons.info,
|
||||
Color? color,
|
||||
}) : _colorOverride = color;
|
||||
|
||||
@@ -463,6 +483,15 @@ class MilerEmptyState extends StatelessWidget {
|
||||
/// different sentences — but the chrome is one thing now.
|
||||
final Widget? illustration;
|
||||
|
||||
/// Edge of the square the [illustration] is drawn in.
|
||||
///
|
||||
/// 180 is right for a screen whose empty state *is* the screen — an empty
|
||||
/// booking queue on a fresh install. It is too much for an empty state that
|
||||
/// is a label on one branch of a populated tab: on Activity the animation was
|
||||
/// the largest object in the app on the one screen with nothing to say, and
|
||||
/// it pushed the sentence answering the rider's question toward the fold.
|
||||
final double illustrationSize;
|
||||
|
||||
/// Anything that belongs under the CTA: a live-listening indicator, a debug
|
||||
/// line. Kept as a slot so a screen does not have to abandon the component to
|
||||
/// add one row.
|
||||
@@ -490,6 +519,7 @@ class MilerEmptyState extends StatelessWidget {
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
this.illustration,
|
||||
this.illustrationSize = 180,
|
||||
this.footer,
|
||||
}) : _accentOverride = accent;
|
||||
|
||||
@@ -505,36 +535,40 @@ class MilerEmptyState extends StatelessWidget {
|
||||
// sizing and the spacing below it stay the component's, so two
|
||||
// screens with different animations still read as one system.
|
||||
if (illustration != null)
|
||||
SizedBox(width: 180, height: 180, child: illustration)
|
||||
SizedBox(
|
||||
width: illustrationSize,
|
||||
height: illustrationSize,
|
||||
child: illustration,
|
||||
)
|
||||
else
|
||||
// Layered halo: a faint outer ring + a slightly stronger inner
|
||||
// disc holding the icon.
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.05),
|
||||
shape: BoxShape.circle,
|
||||
// Layered halo: a faint outer ring + a slightly stronger inner
|
||||
// disc holding the icon.
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.05),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 40,
|
||||
color: accent.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 40,
|
||||
color: accent.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
title,
|
||||
@@ -719,7 +753,8 @@ class _TopBarClose extends StatelessWidget {
|
||||
height: 48,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(icon, size: 24, color: ColorConstants.slateText),
|
||||
// On the brand ground — see [milerPageBar].
|
||||
child: Icon(icon, size: 24, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -758,19 +793,33 @@ PreferredSizeWidget milerPageBar(
|
||||
/// presented modally. Only this gets the cross.
|
||||
bool isModal = false,
|
||||
}) {
|
||||
final IconData backIcon = isModal
|
||||
? Icons.close_rounded
|
||||
: Icons.arrow_back_rounded;
|
||||
final IconData backIcon = isModal ? LucideIcons.x : LucideIcons.arrowLeft;
|
||||
final bool hasControlRow = onBack != null || (actions?.isNotEmpty ?? false);
|
||||
|
||||
return AppBar(
|
||||
// The frosted brand wash every bar in the app shares — see
|
||||
// [milerGlassSurface]. Replaces the flat white, so a pushed page and a tab
|
||||
// read as the same surface at the top.
|
||||
backgroundColor: Colors.transparent,
|
||||
flexibleSpace: milerGlassSurface(),
|
||||
// ── One ground, every bar in the app ──
|
||||
//
|
||||
// This wore the 7% frosted brand wash flattened against white while Home
|
||||
// and the four tabs are solid brand. So pushing Edit Profile, Saved
|
||||
// Addresses, Alert Sound or Notifications changed the colour of the top of
|
||||
// the phone AND the colour of the status-bar glyphs, which reads as leaving
|
||||
// the app rather than opening a page inside it.
|
||||
//
|
||||
// Solid [ColorConstants.primary], and everything on it inverts. The wash
|
||||
// survives as `milerGlassSurface()` for the sheets, which sit over content
|
||||
// and still want it.
|
||||
backgroundColor: ColorConstants.primary,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: ColorConstants.slateText,
|
||||
foregroundColor: Colors.white,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(color: Colors.white),
|
||||
// Dark glyphs on maroon is the one contrast failure a rider cannot work
|
||||
// around by tilting the phone. Matches [MilerAppBar] and Home's header.
|
||||
systemOverlayStyle: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
// Everything lives in `title` so the control row and the heading can be
|
||||
@@ -807,7 +856,7 @@ PreferredSizeWidget milerPageBar(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.pageTitleFixed,
|
||||
style: MilerType.pageTitleFixed.copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -885,6 +934,113 @@ class _RevealState extends State<Reveal> with SingleTickerProviderStateMixin {
|
||||
Duration staggerDelay(int index, {int stepMs = 45, int maxMs = 320}) =>
|
||||
Duration(milliseconds: (index * stepMs).clamp(0, maxMs));
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// GLASS CARD — the one surface every list card in the app is drawn on.
|
||||
///
|
||||
/// A translucent pane, a soft wide shadow, and **no border**.
|
||||
///
|
||||
/// ── Why no border ──
|
||||
///
|
||||
/// A border, a fill and a shadow are three devices doing one job, and the
|
||||
/// border is the one that competes with the content: six bordered rectangles
|
||||
/// down a phone screen read as a form to be filled in, not a route to be
|
||||
/// ridden. Every large logistics app the rider already uses — Uber, Swiggy,
|
||||
/// Amazon Shopper — separates list cards with elevation alone, and it survives
|
||||
/// what a hairline does not: a scratched screen in direct sun, where a 1px
|
||||
/// #E2E8F0 line is simply not there.
|
||||
///
|
||||
/// ── Why one widget rather than a decoration constant ──
|
||||
///
|
||||
/// Two screens draw these cards (Home's route and the work tab's jobs) and they
|
||||
/// drifted apart every single time the recipe was a constant each copied. The
|
||||
/// press feedback belongs here too: a card that opens a sheet ~340ms later
|
||||
/// needs to acknowledge the tap, and that acknowledgement should not be
|
||||
/// something each caller remembers to add.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class GlassCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Opens whatever this card is about. Null makes it a plain surface.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// What a screen reader calls the card.
|
||||
final String? semanticLabel;
|
||||
final String? semanticHint;
|
||||
|
||||
/// A wash over the pane, for the one card on a screen that is *live*.
|
||||
/// Deliberately a whisper — see [ColorConstants.glassCardLive].
|
||||
final Color? tint;
|
||||
|
||||
/// Dims the whole card without changing a single colour inside it, for a
|
||||
/// card that is no longer actionable.
|
||||
final double opacity;
|
||||
|
||||
final double radius;
|
||||
|
||||
const GlassCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.margin,
|
||||
this.padding,
|
||||
this.onTap,
|
||||
this.semanticLabel,
|
||||
this.semanticHint,
|
||||
this.tint,
|
||||
this.opacity = 1,
|
||||
this.radius = 22,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final surface = Container(
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.glassCard,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
// ── The rim is what a budget LCD can actually render ──
|
||||
//
|
||||
// On a calibrated panel the slate shadow separates the pane from the
|
||||
// ground; on the LCD this app ships to, shadows crush to nothing and
|
||||
// the cards read as printed on the page — reported twice from the
|
||||
// device. The one-pixel rim is the same trick the frosted sheets use
|
||||
// ([milerGlassSheet]'s glassRim): a line of light on the curve costs
|
||||
// nothing at any brightness and says "edge" where blur cannot.
|
||||
border: Border.all(color: ColorConstants.borderSubtle),
|
||||
boxShadow: DesignConstants.shadowGlass,
|
||||
),
|
||||
// The tint rides *inside* the pane rather than replacing it, so a live
|
||||
// card is the same glass everything else is, warmed — not a different
|
||||
// material.
|
||||
child: tint == null
|
||||
? child
|
||||
: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: tint,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
final tappable = onTap == null
|
||||
? surface
|
||||
: PressScale(
|
||||
onTap: onTap,
|
||||
// No `semanticLabel` unless the caller asks for one: PressScale
|
||||
// *excludes* its descendants' semantics when it labels itself, and
|
||||
// these cards carry buttons a reader has to be able to reach.
|
||||
semanticLabel: semanticLabel,
|
||||
semanticHint: semanticHint,
|
||||
child: surface,
|
||||
);
|
||||
|
||||
return opacity == 1 ? tappable : Opacity(opacity: opacity, child: tappable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtle press feedback — scales the [child] down slightly while held, then
|
||||
/// springs back. Use it to make cards and custom buttons feel tappable.
|
||||
class PressScale extends StatefulWidget {
|
||||
@@ -1068,11 +1224,21 @@ class SkeletonList extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MilerShimmer(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
for (int i = 0; i < count; i++) skeletonCard(),
|
||||
],
|
||||
// ── Clipped, not overflowed ──
|
||||
//
|
||||
// A bare `Column` of five cards is ~820pt tall, and the body of a pushed
|
||||
// page on the shortest phone the app supports is ~768. So the one screen
|
||||
// whose whole job is to say "this is coming" greeted the rider with a
|
||||
// black-and-yellow overflow band. A skeleton is a placeholder: it should
|
||||
// fill what it is given and stop, never demand a height.
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
for (int i = 0; i < count; i++) skeletonCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1243,7 +1409,7 @@ class MapPlaceholder extends StatelessWidget {
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.place_rounded,
|
||||
LucideIcons.mapPin,
|
||||
size: 30,
|
||||
color: ColorConstants.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
@@ -1354,7 +1520,7 @@ class MilerButton extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: fg,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -1505,10 +1671,7 @@ class MilerCodeBox {
|
||||
|
||||
/// The digit.
|
||||
static TextStyle digit(Color accent, {bool hasError = false}) =>
|
||||
MilerType.figure(
|
||||
20,
|
||||
color: hasError ? ColorConstants.errorRed : accent,
|
||||
);
|
||||
MilerType.figure(20, color: hasError ? ColorConstants.errorRed : accent);
|
||||
}
|
||||
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
@@ -1554,7 +1717,7 @@ class AppFeedback {
|
||||
}) => _show(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.error_outline_rounded,
|
||||
icon: LucideIcons.circleAlert,
|
||||
fill: ColorConstants.errorRed,
|
||||
duration: duration,
|
||||
onRetry: onRetry,
|
||||
@@ -1565,7 +1728,7 @@ class AppFeedback {
|
||||
static void success(BuildContext context, String message) => _show(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.check_circle_rounded,
|
||||
icon: LucideIcons.circleCheck,
|
||||
fill: ColorConstants.acceptGreen,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
@@ -1575,7 +1738,7 @@ class AppFeedback {
|
||||
static void info(BuildContext context, String message) => _show(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.info_outline_rounded,
|
||||
icon: LucideIcons.info,
|
||||
fill: ColorConstants.slateText,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
@@ -1677,7 +1840,7 @@ class ErrorRetry extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MilerEmptyState(
|
||||
icon: Icons.wifi_off_rounded,
|
||||
icon: LucideIcons.wifiOff,
|
||||
title: title,
|
||||
message: message,
|
||||
actionLabel: 'Try again',
|
||||
@@ -1685,3 +1848,182 @@ class ErrorRetry extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHERE THIS PARCEL CAME FROM, AND WHICH ONE IT IS
|
||||
///
|
||||
/// `[ Kitchen 1 ] Bag DG-1042`
|
||||
///
|
||||
/// Driven by the data, not by which line the rider is on: it renders whatever
|
||||
/// of the two facts the stop actually carries, and collapses to nothing when it
|
||||
/// carries neither. A parcel booking has no kitchen and no bag label to send —
|
||||
/// everything on that route came from the one hub the rider started at — so a
|
||||
/// parcel card is unchanged without the widget having to ask which tenant it is
|
||||
/// on. If a parcel payload ever does name a source, showing it is the right
|
||||
/// answer anyway.
|
||||
///
|
||||
/// On a meal run both facts earn their space. A rider part-way through a
|
||||
/// two-kitchen day is carrying two loads that look identical in the box, and
|
||||
/// the failure that costs the client money is not a missed stop — it is the
|
||||
/// right door with the wrong bag, which no OTP, photo or signature catches,
|
||||
/// because the rider is confident and the customer has no way to know. A
|
||||
/// printed label on the card he is already reading is the cheapest thing that
|
||||
/// does.
|
||||
///
|
||||
/// The source is a pill and the bag is plain text, deliberately: the pill is
|
||||
/// scannable down a list, which is what grouping needs, while the label is
|
||||
/// checked once against something physically in his hand.
|
||||
///
|
||||
/// Sized in raw logical pixels like everything else in this file — see the note
|
||||
/// on [MilerEmptyState]; the two stop cards that host it are already
|
||||
/// ScreenUtil-scaled around it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class StopSourceLine extends StatelessWidget {
|
||||
final String source;
|
||||
final String bagLabel;
|
||||
|
||||
/// Dims both when the stop is not actionable, matching the card around it.
|
||||
final bool muted;
|
||||
|
||||
/// Space above the line. Zero when a caller has already placed it in a row of
|
||||
/// its own and is spacing it itself.
|
||||
final double topGap;
|
||||
|
||||
const StopSourceLine({
|
||||
super.key,
|
||||
required this.source,
|
||||
required this.bagLabel,
|
||||
this.muted = false,
|
||||
this.topGap = 6,
|
||||
});
|
||||
|
||||
bool get _isEmpty => source.isEmpty && bagLabel.isEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final accent = muted
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.serviceAccent;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: topGap),
|
||||
child: Row(
|
||||
children: [
|
||||
if (source.isNotEmpty)
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.store, size: 12, color: accent),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
source,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (source.isNotEmpty && bagLabel.isNotEmpty)
|
||||
const SizedBox(width: 8),
|
||||
if (bagLabel.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Bag $bagLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
// Tabular, because these are read as codes and a column of
|
||||
// them down a list should line up.
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHERE THIS ORDER HAS GOT TO
|
||||
///
|
||||
/// `⏱ Assigned` · `✓ Accepted` · `🚲 In progress` · `✔ Completed`
|
||||
///
|
||||
/// One tag, on every card that shows an order, on every screen. The status
|
||||
/// vocabulary already lives in one place — [StopStatusX.label] — and this is
|
||||
/// the matching one place for how it looks, so Home, Bookings and Activity
|
||||
/// cannot end up drawing the same backend state three different ways, which is
|
||||
/// exactly what happened to the words before they were centralised.
|
||||
///
|
||||
/// Icon **and** colour, never colour alone: these are read outdoors, in
|
||||
/// sunlight, on a scratched screen, by somebody who is in a hurry.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class OrderStatusTag extends StatelessWidget {
|
||||
final StopStatus status;
|
||||
|
||||
/// Dims it to match a card that is not actionable.
|
||||
final bool muted;
|
||||
|
||||
/// Sentence-case label. Sizing is raw logical pixels, like the rest of this
|
||||
/// file — the cards hosting it are ScreenUtil-scaled around it.
|
||||
const OrderStatusTag({super.key, required this.status, this.muted = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = muted ? ColorConstants.secondaryText : status.color;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(status.icon, size: 12, color: color),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
status.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user