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:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

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