Files
doormile_customer_app/lib/ui/widgets/pieces.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

568 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'chrome.dart';
enum DmTagTone {
/// The default: a quiet sunken chip carrying a fact — "2 Milers nearby".
neutral,
/// Outlined crimson. The one tag in a group that is a recommendation
/// — "Fastest".
brand,
/// Kept for the delivered state.
ok,
warn,
/// On a crimson field.
onFill,
}
/// Small badge: "Fastest pickup", "Transit", "4 Milers nearby".
class DmTag extends StatelessWidget {
const DmTag(
this.label, {
super.key,
this.tone = DmTagTone.neutral,
this.icon,
this.mono = false,
});
final String label;
final DmTagTone tone;
final IconData? icon;
/// For a vehicle plate or a reference carried as a tag.
final bool mono;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, BoxBorder? edge) = switch (tone) {
DmTagTone.neutral => (DmColors.surfaceAlt, DmColors.ink2, null),
DmTagTone.brand => (
DmColors.surface,
DmColors.brand,
Border.all(color: DmColors.brandLine),
),
DmTagTone.ok => (DmColors.okSoft, DmColors.ok, null),
DmTagTone.warn => (const Color(0xFFFFF3DF), const Color(0xFF9C5303), null),
DmTagTone.onFill => (DmColors.onBrandRaise, Colors.white, null),
};
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 5, 10, 5),
decoration: BoxDecoration(
color: bg,
border: edge,
borderRadius: DmRadius.all(5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: fg),
const SizedBox(width: 5),
],
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: mono
? DmText.monoSm.copyWith(fontSize: 12, color: fg)
: DmText.label.copyWith(fontSize: 12, color: fg),
),
),
],
),
);
}
}
/// A selection chip — the design's `.filt`.
///
/// This is the app's one selection control: it is the sign-in method switch,
/// the address label picker, the slot day filter and the rating tags. Selected
/// is a filled crimson chip with a small glow; unselected is a white chip with
/// a hairline. There is no third state to learn.
class DmChoiceChip extends StatelessWidget {
const DmChoiceChip({
super.key,
required this.label,
required this.selected,
this.enabled = true,
this.onTap,
this.count,
this.expand = false,
});
final String label;
final bool selected;
final bool enabled;
final VoidCallback? onTap;
/// A figure carried inside the chip, as on the Orders filters.
final int? count;
/// Fills the available width — for a row of chips that splits evenly.
final bool expand;
@override
Widget build(BuildContext context) {
// ── A segmented control, not a crimson block ──
//
// "Today" was a solid brand fill with [DmShadow.liftSm] under it, sitting
// eight points above a list of windows and two hundred above the crimson
// button that confirms one. Three crimson objects on a sheet that asks a
// single question, and the loudest of them was the day — which the
// customer changes once, if ever.
//
// It is a lifted white chip on a sunken track now: the same convention
// iOS and every banking app use for a segment, where *selected* means
// raised rather than coloured.
final fg = selected ? DmColors.ink : (enabled ? DmColors.ink3 : DmColors.ink4);
final chip = AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 40),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? DmColors.surface : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
boxShadow: selected ? DmShadow.card : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 13.5,
color: fg,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: selected ? DmColors.surfaceAlt : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: DmColors.ink3,
),
),
),
],
],
),
);
final tappable = Semantics(
button: true,
selected: selected,
enabled: enabled,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap?.call();
}
: null,
child: chip,
),
);
return expand ? Expanded(child: tappable) : tappable;
}
}
/// A destination card in a horizontal strip — the design's `.ccard`.
///
/// Selection is a wash and a crimson edge rather than a fill: a strip of these
/// is scanned, and a solid crimson block in a scrollable row pulls the eye off
/// the neighbours the customer is still comparing.
class DmGridTile extends StatelessWidget {
const DmGridTile({
super.key,
required this.label,
required this.selected,
this.enabled = true,
this.note,
this.onTap,
this.icon,
});
final String label;
final bool selected;
final bool enabled;
final String? note;
final VoidCallback? onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final titleColor = enabled
? (selected ? DmColors.brand : DmColors.ink)
: DmColors.ink4;
final noteColor = selected
? DmColors.brandPress
: (enabled ? DmColors.ink3 : DmColors.ink4);
return Semantics(
button: true,
selected: selected,
enabled: enabled,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap?.call();
}
: null,
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.fromLTRB(13, 12, 13, 12),
decoration: BoxDecoration(
color: selected
? DmColors.brandSoft
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: 18,
color: selected ? DmColors.brand : DmColors.ink3,
),
const SizedBox(width: 10),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(color: titleColor),
),
if (note != null) ...[
const SizedBox(height: 2),
Text(
// Secondary: the promise may ellipsize, the place name
// never does.
note!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny.copyWith(fontSize: 11.5, color: noteColor),
),
],
],
),
),
if (selected)
const Padding(
padding: EdgeInsets.only(left: 6),
child: Icon(LucideIcons.check, size: 17, color: DmColors.brand),
),
],
),
),
),
);
}
}
/// "Step 2 of 4 · Pickup" with its segmented rail.
class DmStepHeader extends StatelessWidget {
const DmStepHeader({
super.key,
required this.step,
required this.total,
required this.label,
});
final int step;
final int total;
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
child: DmStepBar(
progress: step / total,
steps: total,
current: step,
label: label,
),
);
}
}
/// A washed strip carrying one piece of supporting information.
class DmInfoBanner extends StatelessWidget {
const DmInfoBanner({
super.key,
required this.icon,
required this.title,
this.message,
this.tone = DmTagTone.brand,
});
final IconData icon;
final String title;
final String? message;
final DmTagTone tone;
@override
Widget build(BuildContext context) {
final (bg, edge, fg, sub) = switch (tone) {
DmTagTone.ok => (DmColors.okSoft, DmColors.okSoft, DmColors.ok, DmColors.ok),
DmTagTone.warn => (
const Color(0xFFFFF3DF),
const Color(0xFFF6E3C2),
const Color(0xFF9C5303),
const Color(0xFF9C5303),
),
DmTagTone.neutral => (
DmColors.surfaceAlt,
DmColors.border,
DmColors.ink,
DmColors.ink2,
),
_ => (
DmColors.brandSoft,
DmColors.brandLine,
DmColors.ink,
DmColors.brandPress,
),
};
return Container(
padding: const EdgeInsets.fromLTRB(13, 13, 14, 13),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: edge),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: tone == DmTagTone.brand ? DmColors.brandSoft : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(
icon,
size: 18,
color: tone == DmTagTone.brand ? DmColors.brand : fg,
),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: DmText.cardTitle.copyWith(fontSize: 14, color: fg)),
if (message != null) ...[
const SizedBox(height: 2),
Text(message!, style: DmText.small.copyWith(color: sub)),
],
],
),
),
],
),
);
}
}
/// A reference number with a copy affordance. The number is mono and large
/// enough to read back over a phone.
class DmCopyRow extends StatefulWidget {
const DmCopyRow({
super.key,
required this.label,
required this.value,
this.trailing,
this.onWash = false,
});
final String label;
final String value;
final Widget? trailing;
/// Sitting inside a wash card, where the label goes crimson.
final bool onWash;
@override
State<DmCopyRow> createState() => _DmCopyRowState();
}
class _DmCopyRowState extends State<DmCopyRow> {
bool _copied = false;
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.value));
await HapticFeedback.mediumImpact();
if (!mounted) return;
// The confirmation lands on the control that was pressed rather than in a
// snackbar over the footer: it is the row the customer is looking at.
setState(() => _copied = true);
await Future<void>.delayed(const Duration(milliseconds: 1600));
if (mounted) setState(() => _copied = false);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.label.toUpperCase(),
style: DmText.eyebrow.copyWith(
color: widget.onWash ? DmColors.brand : DmColors.ink3,
),
),
const SizedBox(height: 4),
Text(
widget.value,
style: DmText.monoLg.copyWith(
color: widget.onWash ? DmColors.brandPress : DmColors.ink,
),
),
],
),
),
?widget.trailing,
DmTextAction(
label: _copied ? 'Copied' : 'Copy',
icon: _copied ? LucideIcons.check : LucideIcons.copy,
onPressed: _copy,
),
],
);
}
}
/// − n + quantity control. Used for how many packages go to one place.
class DmStepper extends StatelessWidget {
const DmStepper({
super.key,
required this.value,
required this.onChanged,
this.min = 1,
this.canIncrease = true,
});
final int value;
final ValueChanged<int> onChanged;
final int min;
/// False when a cap has been reached; the + greys out rather than vanishing.
final bool canIncrease;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_Step(
icon: LucideIcons.minus,
onTap: value > min ? () => onChanged(value - 1) : null,
),
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 30),
child: Text(
'$value',
textAlign: TextAlign.center,
style: DmText.monoLg.copyWith(fontSize: 17),
),
),
_Step(
icon: LucideIcons.plus,
onTap: canIncrease ? () => onChanged(value + 1) : null,
),
],
),
);
}
}
class _Step extends StatelessWidget {
const _Step({required this.icon, this.onTap});
final IconData icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Semantics(
button: true,
enabled: enabled,
child: InkResponse(
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap!();
}
: null,
radius: 20,
child: SizedBox(
width: 44,
height: 44,
child: Icon(
icon,
size: 18,
color: enabled ? DmColors.ink : DmColors.ink4,
),
),
),
);
}
}