Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
470
lib/ui/widgets/cards.dart
Normal file
470
lib/ui/widgets/cards.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
/// How loudly a card speaks.
|
||||
enum DmCardTone {
|
||||
/// White on the canvas, hairline edge. Almost everything.
|
||||
plain,
|
||||
|
||||
/// Filled crimson. **One per screen, at most** — it is the screen's answer
|
||||
/// to "what is happening right now": the live ETA, the booked confirmation,
|
||||
/// the delivered summary. A second one on the same screen and neither wins.
|
||||
fill,
|
||||
|
||||
/// A crimson wash. Supporting brand information that must not shout: the
|
||||
/// address being collected from, the rider on the way, a reassurance.
|
||||
wash,
|
||||
|
||||
/// The canvas itself, edged. For content that is deliberately inert — the
|
||||
/// build panel on Account.
|
||||
sunken,
|
||||
}
|
||||
|
||||
/// A bordered panel that stacks rows and rules them apart.
|
||||
///
|
||||
/// This replaces the shadowed card the app used to draw everywhere. The rule
|
||||
/// between rows belongs to the card, not to the rows: a row does not know
|
||||
/// whether it is first, and a card that is handed one child draws no rule at
|
||||
/// all, so the same [DmCardCell] works alone or in a stack of five.
|
||||
class DmCard extends StatelessWidget {
|
||||
const DmCard({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.tone = DmCardTone.plain,
|
||||
this.rail = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final DmCardTone tone;
|
||||
|
||||
/// A 3pt crimson spine down the leading edge — marks the one row in a list
|
||||
/// that needs the customer's attention now.
|
||||
final bool rail;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
Color get _background => switch (tone) {
|
||||
DmCardTone.plain => DmColors.surface,
|
||||
DmCardTone.fill => DmColors.brand,
|
||||
DmCardTone.wash => DmColors.brandSoft,
|
||||
DmCardTone.sunken => DmColors.canvas,
|
||||
};
|
||||
|
||||
Color get _edge => switch (tone) {
|
||||
DmCardTone.plain => DmColors.border,
|
||||
DmCardTone.fill => DmColors.brand,
|
||||
DmCardTone.wash => DmColors.brandLine,
|
||||
DmCardTone.sunken => DmColors.border,
|
||||
};
|
||||
|
||||
/// The rule between two rows of the same card. On crimson it is a lightened
|
||||
/// white rather than a darker line: a dark rule on a saturated field reads as
|
||||
/// a crack, a light one as a seam.
|
||||
Color get _rule => switch (tone) {
|
||||
DmCardTone.plain => DmColors.hairline,
|
||||
DmCardTone.fill => const Color(0x2EFFFFFF),
|
||||
DmCardTone.wash => DmColors.brandLine,
|
||||
DmCardTone.sunken => DmColors.border,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The edge is drawn in the FOREGROUND, over the children.
|
||||
//
|
||||
// As a background border it was painted first and then covered: a cell with
|
||||
// its own tint — the washed status strip at the head of an order card, the
|
||||
// crimson rows on the booked screen — filled the whole box, border included,
|
||||
// and the card lost its outline exactly where it needed one most. A
|
||||
// foreground decoration paints last, so every cell is clipped inside an
|
||||
// edge that is always visible.
|
||||
final card = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _background,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: _edge),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) Container(height: 1, color: _rule),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final railed = rail
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 3),
|
||||
child: card,
|
||||
)
|
||||
: card;
|
||||
|
||||
if (onTap == null) return railed;
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: railed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One padded cell inside a [DmCard] — the design's `.p`.
|
||||
class DmCardCell extends StatelessWidget {
|
||||
const DmCardCell({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||||
this.background,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// Tints this cell alone — a wash strip at the head of a plain card.
|
||||
final Color? background;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = Padding(padding: padding, child: child);
|
||||
final painted = background == null
|
||||
? body
|
||||
: ColoredBox(color: background!, child: body);
|
||||
|
||||
if (onTap == null) return painted;
|
||||
return Material(
|
||||
color: background ?? Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft.withValues(alpha: 0.6),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The common cell: a glyph or tile, a title with an optional second line, and
|
||||
/// something on the right — a value, a chevron, an inline action.
|
||||
class DmCardRow extends StatelessWidget {
|
||||
const DmCardRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.eyebrow,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.tone = DmCardTone.plain,
|
||||
this.onTap,
|
||||
this.titleStyle,
|
||||
this.subtitleStyle,
|
||||
this.background,
|
||||
this.crossAlign = CrossAxisAlignment.center,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// A micro-label above the title — "COLLECTING FROM".
|
||||
final String? eyebrow;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
|
||||
/// The tone of the *card this row sits in*, so the row can pick text colours
|
||||
/// that survive a crimson field.
|
||||
final DmCardTone tone;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final TextStyle? titleStyle;
|
||||
final TextStyle? subtitleStyle;
|
||||
final Color? background;
|
||||
final CrossAxisAlignment crossAlign;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onFill = tone == DmCardTone.fill;
|
||||
final titleColor = onFill ? Colors.white : DmColors.ink;
|
||||
final subColor = switch (tone) {
|
||||
DmCardTone.fill => DmColors.onBrand2,
|
||||
DmCardTone.wash => DmColors.brandPress,
|
||||
_ => DmColors.ink2,
|
||||
};
|
||||
final eyebrowColor = switch (tone) {
|
||||
DmCardTone.fill => DmColors.onBrand3,
|
||||
DmCardTone.wash => DmColors.brand,
|
||||
_ => DmColors.ink3,
|
||||
};
|
||||
|
||||
return DmCardCell(
|
||||
background: background,
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
crossAxisAlignment: crossAlign,
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (eyebrow != null) ...[
|
||||
Text(
|
||||
eyebrow!.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(color: eyebrowColor),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: (titleStyle ?? DmText.cardTitle).copyWith(
|
||||
color: titleStyle?.color ?? titleColor,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: (subtitleStyle ?? DmText.small).copyWith(
|
||||
color: subtitleStyle?.color ?? subColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [DmTile] is filled.
|
||||
enum DmTileTone { sunken, wash, brand, onFill }
|
||||
|
||||
/// The 38pt rounded square that holds a glyph or a step number.
|
||||
///
|
||||
/// Square, not a circle: the cards, fields and buttons around it are all cut
|
||||
/// with the same small radius, and a disc in the middle of that is the one
|
||||
/// shape that has to be explained.
|
||||
class DmTile extends StatelessWidget {
|
||||
const DmTile({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.label,
|
||||
this.tone = DmTileTone.sunken,
|
||||
this.size = 38,
|
||||
}) : assert(icon != null || label != null, 'a tile shows a glyph or a number');
|
||||
|
||||
final IconData? icon;
|
||||
|
||||
/// A step number, for the "how it works" sequence.
|
||||
final String? label;
|
||||
final DmTileTone tone;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bg, fg) = switch (tone) {
|
||||
DmTileTone.sunken => (DmColors.surfaceAlt, DmColors.ink),
|
||||
DmTileTone.wash => (DmColors.brandSoft, DmColors.brand),
|
||||
DmTileTone.brand => (DmColors.brand, Colors.white),
|
||||
DmTileTone.onFill => (DmColors.onBrandRaise, Colors.white),
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: icon != null
|
||||
? Icon(icon, size: size * 0.47, color: fg)
|
||||
: Text(
|
||||
label!,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontVariations: const [FontVariation('wght', 800)],
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A figure and its caption, sat in its own bordered box. Used in pairs.
|
||||
class DmMetric extends StatelessWidget {
|
||||
const DmMetric({
|
||||
super.key,
|
||||
required this.figure,
|
||||
required this.caption,
|
||||
this.tone = DmCardTone.plain,
|
||||
});
|
||||
|
||||
final String figure;
|
||||
final String caption;
|
||||
final DmCardTone tone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bg, edge, figureColor, captionColor) = switch (tone) {
|
||||
DmCardTone.fill => (
|
||||
DmColors.brand,
|
||||
DmColors.brand,
|
||||
Colors.white,
|
||||
DmColors.onBrand3,
|
||||
),
|
||||
DmCardTone.wash => (
|
||||
DmColors.brandSoft,
|
||||
DmColors.brandLine,
|
||||
DmColors.brand,
|
||||
DmColors.brandPress,
|
||||
),
|
||||
_ => (DmColors.surface, DmColors.border, DmColors.ink, DmColors.ink3),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(15, 14, 15, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: edge),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
figure,
|
||||
style: DmText.display.copyWith(fontSize: 25, height: 1, color: figureColor),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
caption,
|
||||
style: DmText.value.copyWith(fontSize: 12.5, color: captionColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A label and its figure on one baseline — the fare breakdown, the build
|
||||
/// panel. The label may wrap; the figure never does.
|
||||
class DmFareLine extends StatelessWidget {
|
||||
const DmFareLine({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.strong = false,
|
||||
this.mono = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
/// The total, rather than one of the lines that make it up.
|
||||
final bool strong;
|
||||
|
||||
/// Figures are mono by default — they are meant to be compared down a column.
|
||||
final bool mono;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.small.copyWith(
|
||||
fontWeight: strong ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
style: mono
|
||||
? DmText.mono.copyWith(
|
||||
fontSize: strong ? 15 : 13.5,
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
)
|
||||
: DmText.value.copyWith(
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The uppercase micro-label that heads a group of cards.
|
||||
class DmMicroHead extends StatelessWidget {
|
||||
const DmMicroHead(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.brand = true,
|
||||
this.top = 20,
|
||||
this.bottom = 10,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
/// Crimson heads brand content; neutral heads everything else.
|
||||
final bool brand;
|
||||
final double top;
|
||||
final double bottom;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: top, bottom: bottom),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
text.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(
|
||||
color: brand ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user