Pages fall away downward, the pickup comes back, and a real icon set

── Navigation ──

Pages arrived from the right and left to the right. Right is the direction
the system back gesture drags, so a page leaving under a button press read as
a swipe nobody made — and every push in this app is a layer over the one
before it, which arrives from below. The axis is vertical now: the incoming
page rises and covers, the page underneath stays put and dims, and back drops
the top page off the bottom. Full travel rather than the old 14% nudge, which
is what makes the direction nameable. test/page_transition_test.dart holds it.

── Home ──

The pickup pill is back under the greeting. It was cut on the argument that
the form below already carried the pickup; the form is four hundred points
down the screen, and a location you can only see by scrolling to a form is a
location you do not trust. It shows the locality while the form keeps the
door, so the screen answers one question once, and it now has a resolving
state — it used to print the last place you were, confidently, while the fix
was still in flight.

The sphere's caption was falling off the screen. Adding the pill pushed it out
of the scroll viewport, silently, with no overflow warning — the fourth time
Home growing has clipped something under the sphere. DmBookOrb.field is no
longer a constant: the glow gives way to whatever is left, the sphere never
does.

── 74 points of dead canvas, on every root ──

Home, Orders and Account each cleared the floating tab bar by
`MediaQuery.paddingOf(context).bottom + 74`, on the reasoning that the inset is
the home indicator and 74 is the bar above it. Scaffold with extendBody
replaces the body's bottom padding with the bar's whole laid-out height —
measured, a 34pt inset and this bar hand the body 106. They were clearing the
bar, then clearing it again. One helper now, dmTabBarClear().

── One journey, one vocabulary ──

Review said ORIGIN/DESTINATION; Home's form and tracking say PICKUP/DROP.
Three screens a customer walks in order. The tracking card had already written
down the argument; the booking screen hadn't followed.

The rail's first milestone read "Booking created" directly above "Order
created" — down the rail: created, created, in transit. It is "Pickup booked",
which is what JourneyStage.milestoneLabel has called that stage all along on
Orders and on Home's live row.

── The launcher icon ──

tool/icons.py renders all twenty-eight files from one master, because the
failure mode of exporting by hand is nineteen replaced and nine left on the
old mark, at the density nobody checks.

The master is a bare mark on a field, so it cannot be dropped into Android's
adaptive canvas whole: crimson is the background, the white mark is the
foreground, and it is sized by its reach from the canvas centre so a round
mask cannot take the arrow tip. Measured on the built file: 63.7dp against a
66dp safe zone. A real monochrome layer replaces the colour foreground that
was standing in for one — the system tints by alpha, so that themed as a blob.
ic_launcher_round.png exists at last; minSdk is 24 and android:roundIcon had
nothing to resolve to below API 26.

The artwork is flattened (the source vignettes over ten values of red) and its
horizontal offset is placed deliberately rather than inherited — geometric
centring made the mark lean, because the arrow carries the bounding box right
while contributing almost none of the ink.

── Bundle ──

pubspec declared assets/images/ as a folder, which shipped the 974 KB icon
master to every customer for a file no code opens. Named explicitly now.
This commit is contained in:
2026-09-25 13:34:52 +05:30
parent 8956e818a4
commit 49b0de0d8f
72 changed files with 897 additions and 92 deletions

View File

@@ -158,7 +158,7 @@ class AccountScreen extends StatelessWidget {
DmSpace.pad,
4,
DmSpace.pad,
74 + MediaQuery.paddingOf(context).bottom,
dmTabBarClear(context),
),
children: [
const DmMicroHead('Preferences', top: 4, brand: false),

View File

@@ -414,8 +414,20 @@ class _RouteHead extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── The same two words the rest of the app uses ──
//
// This said ORIGIN and DESTINATION. Tracking says PICKUP and DROP, and
// so does the pick/drop form on Home — the argument is recorded on the
// tracking card and it is right: those are logistics words for a
// consignment, and the customer's parcel is collected from somewhere
// and dropped somewhere.
//
// One journey was being named twice, in two vocabularies, on three
// screens a customer walks through in order. Whichever pair is better
// matters less than there being one pair, and the other two surfaces
// had already chosen.
_Stop(
label: 'ORIGIN',
label: 'PICKUP',
value: from,
note: fromNote,
onTap: onTapFrom,
@@ -424,7 +436,7 @@ class _RouteHead extends StatelessWidget {
),
for (var i = 0; i < to.length; i++)
_Stop(
label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}',
label: to.length == 1 ? 'DROP' : 'DROP ${i + 1}',
// BOOK no longer asks where it is going, so this row is where the
// question lands. Unset it reads as an invitation rather than as
// the em dash `shortLabel` falls back to.

View File

@@ -135,7 +135,10 @@ class _HomeScreenState extends State<HomeScreen> {
// this is the top of the screen, so it takes the inset the bar used
// to hold.
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 26, DmSpace.pad, 0),
// 18, not 26. The inset was set when the greeting was one line
// with nothing under it; the pill below it now carries its own
// height, and 26 on top of that started the screen twice.
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 18, DmSpace.pad, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -176,6 +179,15 @@ class _HomeScreenState extends State<HomeScreen> {
),
],
),
const SizedBox(height: 12),
_PickupLine(
area: _areaOf(app.pickup),
resolving: app.resolvingPickup,
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) app.setPickup(place);
},
),
],
),
),
@@ -246,6 +258,20 @@ class _HomeScreenState extends State<HomeScreen> {
DmBookOrb(
onTap: _book,
held: _handingOver,
// ── The caption used to fall off ──
//
// The sphere's glow field was a constant
// 320, the caption sat under it inside a
// scroll viewport, and every time Home
// grew — a live card, a pickup pill — the
// viewport clipped the caption in silence.
//
// The field is now whatever is left once
// the caption has its line. See
// [DmBookOrb.field]; it clamps itself, so
// a very short screen shrinks the glow
// rather than eating the sentence.
field: box.maxHeight - _captionRoom,
// Short enough to sit on one line, and
// written to be read against the DROP
// row's note rather than on its own:
@@ -288,7 +314,11 @@ class _HomeScreenState extends State<HomeScreen> {
// round it, so a chip pinned directly under the column overlapped
// the shadow the sphere throws.
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
// 14 on top: the sphere's caption ends where this begins, and with
// nothing between them the sentence read as the card's heading.
// It costs the sphere 14 points of glow and nothing else — the
// field is whatever is left over now. See [DmBookOrb.field].
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0),
child: _PickDropForm(
pickup: app.pickup?.title,
onPickup: () async {
@@ -299,22 +329,42 @@ class _HomeScreenState extends State<HomeScreen> {
).animate().fadeIn(duration: 380.ms, delay: 300.ms),
),
// Clears the floating tab bar, which the shell stacks over the body.
//
// Measured, not guessed. A fixed 74 was right on the 844pt golden
// and eight points short on a device with a home indicator, where
// the bar is its own 58 plus 10 of padding plus a 34pt inset — so
// the pill sat on top of the live card.
SizedBox(height: MediaQuery.paddingOf(context).bottom + 74),
// Clears the floating tab bar — see [dmTabBarClear], which also
// records why this used to be twice as tall as it needed to be.
SizedBox(height: dmTabBarClear(context)),
],
),
);
}
/// The line under the sphere, plus its gap. Reserved out of the sphere's
/// field rather than laid out after it and hoped for.
static const _captionRoom = 28.0;
static String _firstName(String? name) {
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
return first.isEmpty ? 'there' : first;
}
/// The locality the pickup sits in — `Gandhipuram, Coimbatore 641012` — as
/// distinct from the door, which is [Place.title].
///
/// ── Why the pill and the form do not print the same string ──
///
/// Both are the pickup, and if both said `12 Nehru Street` the screen would
/// be answering one question twice, four hundred points apart. They are not
/// the same question. The pill under the customer's name is *where Doormile
/// is serving you* — the thing you check at a glance and change when you
/// have travelled. The PICKUP row in the form is *the door a Miler rings*,
/// and it wants the number and the street.
///
/// So the pill takes the area line and the form keeps the door. When the
/// geocoder sends no area, the door is better than an empty pill.
static String? _areaOf(Place? place) {
if (place == null) return null;
final sub = place.sub.trim();
return sub.isEmpty ? place.title : sub;
}
}
/// The live booking, at the head of Home.
@@ -460,6 +510,107 @@ class _LiveCard extends StatelessWidget {
}
}
/// Where Doormile is serving this customer, under their name.
///
/// ── It went missing, and the screen lost its footing ──
///
/// This pill has been most shapes — box, line, pill, row — and at one point it
/// was taken off Home entirely on the argument that the booking form below
/// already carried the pickup. The argument was wrong in a way that is only
/// obvious once it is gone: the form is four hundred points down the screen,
/// under a sphere, and a customer who has just got off a train wants to know
/// *before* they touch anything whether the app has followed them. A location
/// you can only see by scrolling to a form is a location you do not trust.
///
/// So it is back, directly under the greeting, where the first glance lands.
/// A brand pin, the locality, and a chevron-down — down rather than right,
/// because what it opens is a chooser and not another screen.
///
/// ── The state it never used to show ──
///
/// It printed whatever [AppState.pickup] held and nothing else, so while the
/// fix and the reverse-geocode were in flight it showed the *last* place the
/// customer was — confidently, with a pin beside it. Now the resolving case is
/// its own: a muted pin, a dimmed line, and the word for what is happening.
class _PickupLine extends StatelessWidget {
const _PickupLine({
required this.area,
required this.resolving,
required this.onTap,
});
/// The locality line, or null when nothing has been resolved yet.
final String? area;
/// A fix or a reverse-geocode is in flight.
final bool resolving;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final unset = area == null;
final label = resolving
? 'Finding your location\u2026'
: (area ?? 'Set a pickup address');
// Muted while it is a guess or a prompt; full brand once it is an answer.
final pinColour = resolving || unset ? DmColors.ink4 : DmColors.brand;
return Semantics(
button: true,
label: resolving
? 'Finding your pickup location'
: 'Picking up from $label. Change',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 40, maxWidth: 300),
padding: const EdgeInsets.fromLTRB(11, 8, 11, 8),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
border: Border.all(color: DmColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.mapPin, size: 15, color: pinColour),
const SizedBox(width: 8),
Flexible(
child: AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.cardTitle.copyWith(
fontSize: 14,
color: resolving || unset ? DmColors.ink3 : null,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 6),
const Icon(
LucideIcons.chevronDown,
size: 15,
color: DmColors.ink4,
),
],
),
),
),
),
);
}
}
/// Pickup over drop, on one card — the shape every ride app asks a journey in.
///
/// ── Why the app has two ways to start and only one of them is a sphere ──

View File

@@ -133,7 +133,7 @@ class _OrdersScreenState extends State<OrdersScreen> {
DmSpace.pad,
0,
DmSpace.pad,
74 + MediaQuery.paddingOf(context).bottom,
dmTabBarClear(context),
),
itemCount: rows.isEmpty ? 1 : rows.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),