One Touch, and a pickup map worth the name

Two ways to book the same parcel, and the difference said where the
choice is made rather than in a paragraph about it.

ONE TOUCH is the sphere: a state, a district, a window, and nothing
else. The door, the recipient and the weight go out omitted, which the
contract already treats as "not added yet" - the Miler writes them down
standing at the address with the parcel in hand, which is the only
moment anybody knows them.

The pick/drop form under it is the other way in. PICKUP already carries
the detected address; DROP is the only row with a question on it, and
it asks for the full door - once per destination - before the window.
Captions rather than an explainer: "Just a city and a time" under the
sphere, "Enter the full address yourself" on the row.

The pickup screen is a map screen now instead of a screen with a map on
it. Full-bleed basemap, pin nailed to the centre, floating search and
back, and a fixed sheet carrying what the pin resolved to. The
collect-from sheet can hand off to it and carry the pin back.

Also:

* Home reads what-is-running, then the one gesture, then the form. The
  live card lost its courier row and half its height.
* The sphere turns inside out on press - white floods from the centre
  and the word inverts through the clip, not by tween - and bounces
  back on release.
* Order IDs copy, on Orders and on tracking.
* Tracking says PICKUP and DROP with an arrow between them, carries the
  reference at the foot of the card it identifies, and shows every
  timing exactly once.
* The wordmark bar is gone from all three roots; Orders and Account had
  no SafeArea under it, so their titles were sitting under the notch.

flutter analyze: clean. flutter test: 90 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-24 15:59:06 +05:30
parent 86b6af48c2
commit 8956e818a4
42 changed files with 2059 additions and 956 deletions

View File

@@ -60,8 +60,7 @@ class DmBookOrb extends StatefulWidget {
const DmBookOrb({
super.key,
required this.onTap,
this.label = 'BOOK NOW',
this.strapline = 'INSTANT PARCEL',
this.label = 'ONE TOUCH',
this.caption,
this.held = false,
});
@@ -81,9 +80,6 @@ class DmBookOrb extends StatefulWidget {
/// second use of this widget elsewhere is not forced to repeat itself.
final String? caption;
/// The small line inside the sphere, under the label.
final String strapline;
@override
State<DmBookOrb> createState() => _DmBookOrbState();
}
@@ -104,12 +100,33 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
duration: const Duration(milliseconds: 4000),
)..repeat(reverse: true);
/// The press turning the sphere inside out.
///
/// White floods from the centre to the rim and the word flips to crimson —
/// the inverse of the resting state, which is what makes it read as the
/// button having been *pressed* rather than as a new object appearing. It
/// holds while the sheet it opened is up and drains back on the way out.
late final AnimationController _reveal = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
reverseDuration: const Duration(milliseconds: 300),
);
bool _down = false;
@override
void didUpdateWidget(covariant DmBookOrb old) {
super.didUpdateWidget(old);
// Held while the destination sheet is up; released when the flow returns
// or is dismissed, and the sphere fills back in.
if (old.held && !widget.held) _reveal.reverse();
}
@override
void dispose() {
_radar.dispose();
_breath.dispose();
_reveal.dispose();
super.dispose();
}
@@ -121,19 +138,23 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
return Semantics(
button: true,
label: widget.caption == null
? '${widget.label}. ${widget.strapline}'
: '${widget.label}. ${widget.strapline}. ${widget.caption}',
? widget.label
: '${widget.label}. ${widget.caption}',
child: Column(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapCancel: () => setState(() => _down = false),
onTapCancel: () {
setState(() => _down = false);
_reveal.reverse();
},
onTap: () {
setState(() => _down = false);
// The press is a commitment — it opens the booking flow — and a
// sphere gives no edge feedback the way a bordered button does.
HapticFeedback.mediumImpact();
_reveal.forward();
widget.onTap();
},
child: SizedBox(
@@ -154,20 +175,32 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
),
),
),
// ── In, then out ──
//
// Down is quick and flat: 140ms of `easeOutCubic`, so the
// finger feels the give rather than watching it happen. Up
// is slower and overshoots — `easeOutBack` carries the scale
// past 1 before it settles — which is the small outward
// bounce a physical button gives back when you let go.
AnimatedScale(
scale: _down || widget.held ? 0.94 : 1,
// Just past the press, so the finger feels the give rather
// than watching it happen.
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
scale: _down || widget.held ? 0.93 : 1,
duration: Duration(
milliseconds: _down || widget.held ? 140 : 420,
),
curve: _down || widget.held
? Curves.easeOutCubic
: Curves.easeOutBack,
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 1.012).animate(
CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
),
child: _Sphere(
size: disc,
label: widget.label,
strapline: widget.strapline,
child: AnimatedBuilder(
animation: _reveal,
builder: (_, _) => _Sphere(
size: disc,
label: widget.label,
reveal: Curves.easeOutCubic.transform(_reveal.value),
),
),
),
),
@@ -195,12 +228,15 @@ class _Sphere extends StatelessWidget {
const _Sphere({
required this.size,
required this.label,
required this.strapline,
required this.reveal,
});
final double size;
final String label;
final String strapline;
/// 0 at rest, 1 fully pressed. Drives the white circle that opens from the
/// centre and the crimson word laid over it.
final double reveal;
@override
Widget build(BuildContext context) {
@@ -273,57 +309,113 @@ class _Sphere extends StatelessWidget {
//
// It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking,
// which filled the sphere edge to edge and read as a warning label.
// The reference sets a glyph, a sentence-case line and a small
// strapline — the same three parts every physical button that has
// ever been designed well carries, and the air round them is what
// makes the circle read as an object rather than as a word in a
// red disc.
Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.navigation,
size: 21,
color: Colors.white,
// A glyph over a single line, with air round it, is what makes the
// circle read as an object rather than as a word in a red disc.
//
// "INSTANT PARCEL" sat under it and is gone: on a screen whose only
// other text is the customer's own name and their address, a
// strapline is the app talking about itself.
_face(Colors.white, shadowed: true),
// ── The press, turning it inside out ──
//
// A white disc opens from the centre to the rim, clipped to the
// sphere, with the word re-drawn crimson on top of it. Two complete
// faces rather than an interpolated colour: the word has to invert
// *as the white passes under it*, and only a clip can do that — a
// tween would fade the whole label at once while half of it is still
// sitting on red.
if (reveal > 0)
Positioned.fill(
child: ClipPath(
clipper: _Reveal(reveal),
child: Stack(
alignment: Alignment.center,
children: [
// White, with a crimson hairline round it. Without the
// ring the pressed state is a white disc on a near-white
// page — the shape the whole screen is built around
// dissolves at exactly the moment it is being pressed.
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: DmColors.brandLine,
width: 1.5,
),
),
),
),
_face(DmColors.brand),
],
),
),
const SizedBox(height: 11),
Text(
label,
textAlign: TextAlign.center,
style: DmText.heading.copyWith(
fontSize: 23,
height: 1.05,
letterSpacing: -0.5,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
color: Colors.white,
shadows: const [
),
],
),
);
}
/// One face, in whichever ink the layer under it calls for.
///
/// The drop shadow belongs to the white word only — it is there to lift type
/// off a saturated gradient, and under crimson on white it would read as
/// grime.
Widget _face(Color ink, {bool shadowed = false}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.navigation, size: 21, color: ink),
const SizedBox(height: 11),
Text(
label,
textAlign: TextAlign.center,
style: DmText.heading.copyWith(
fontSize: 23,
height: 1.05,
letterSpacing: -0.5,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
color: ink,
shadows: shadowed
? const [
Shadow(
color: Color(0x4D5C0802),
blurRadius: 12,
offset: Offset(0, 2),
),
],
),
),
const SizedBox(height: 5),
Text(
strapline,
textAlign: TextAlign.center,
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 1.7,
color: const Color(0xB3FFFFFF),
),
),
],
]
: null,
),
],
),
),
],
);
}
}
/// A circle growing from the centre of the sphere to its rim.
class _Reveal extends CustomClipper<Path> {
const _Reveal(this.t);
final double t;
@override
Path getClip(Size size) => Path()
..addOval(
Rect.fromCircle(
center: Offset(size.width / 2, size.height / 2),
// A shade past the rim at full extent, so the last frame is a clean
// white disc rather than a hairline of crimson round the edge.
radius: size.width / 2 * t * 1.02,
),
);
@override
bool shouldReclip(_Reveal old) => old.t != t;
}
/// A rim that changes with the light: white along the top arc, shadow along
/// the bottom, nothing at the sides.
class _RimPainter extends CustomPainter {