Files
Thiru-tenext c7a74c57b8 BOOK NOW, OpenStreetMap, and one segmented control instead of two
── The circle says what pressing it does ──

"ONE TOUCH" named the *mechanism* — one gesture, no form — which is something
the product team knows and a customer has to be taught. Nobody arrives at that
screen wanting a touch; they want a parcel collected. The caption under the
sphere still carries what makes it different from the form below.

Renamed in the comments too. A codebase explaining "One Touch" against a button
that says BOOK NOW is a trap for whoever reads it next.

── OpenStreetMap everywhere ──

One line: the default provider was CARTO, it is `osm`. Nothing else moves —
`DmMapTiles` already reads the template, subdomains, retina flag and
attribution off the provider, so the credit line follows on its own.

One thing recorded on the provider itself rather than left to be discovered:
these are donated servers and the OSM Foundation's tile policy does not permit
a distributed app to lean on them. A block looks like every tile turning into
the ground colour at once, with no other symptom. Moving off it is one define —
`DM_MAP_PROVIDER=carto|maptiler|stadia`, all serving OpenStreetMap data — and
the map_config test now asserts the identifying User-Agent rather than only the
URL, because that is what attributable traffic depends on.

── Orders had a second copy of the segmented control ──

Its own `_Tab`, a pill radius, 3pt of padding and the count folded into the
label's text — beside the pickup window's day switcher, which is DmChoiceChip
in a rounded groove with 4pt of padding and the count in a bubble. Two controls
doing one job, drifting apart a padding value at a time.

It is the same control now, and `_Tab` is gone. DmChoiceChip's horizontal
padding drops 12 → 9: three of them split a 390pt phone and "Cancelled"
truncated to "Cancell…" at the old value. The day switcher has two chips and
acres of room, so it loses nothing.
2026-09-30 11:19:30 +05:30

572 lines
16 KiB
Dart
Raw Permalink 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),
// 9, not 12. Three of these split a 390pt phone between them on the
// Orders filter, and at 12 the widest label truncated to "Cancell…".
// The pickup window's day switcher has two chips and acres of room, so
// it loses nothing.
padding: const EdgeInsets.symmetric(horizontal: 9, 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: 5),
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,
),
),
),
);
}
}