33 KiB
Miler — the Doormile rider app
Miler Rider — Smarter Pickups, Powered by AI Flutter · Android + iOS ·
com.doormile.partner· v1.2.28+144
This is the single reference for what this app is, who it serves, how it is built, and how it was designed. If you are new to the codebase, read sections 1–3. If you are about to change UI, read sections 5–7 before you write a line.
Contents
- What Miler is
- The people and the workflow
- The routing problem underneath
- Architecture
- Design principles
- The design system
- Screen by screen — what was designed and why
- Backend integration and known gaps
- What we are doing with this app now
- Conventions for anyone touching this code
1. What Miler is
Miler is the rider app built by Doormile, a logistics company that moves parcels between customers and its hubs. It is the tool carried by the field agent we call a "miler" — the person on a bike who actually collects and delivers packages on the ground.
The system works like this. Customers use the separate Doormile customer app to book a time slot — a window in which they want a parcel picked up or delivered. Behind the scenes, for each slot, a hub manager (admin) gathers all the customer bookings falling in that window and assigns them to one miler as a single ordered route — Stop 1, Stop 2, Stop 3, through to the last stop. That route appears inside this app in the exact sequence the admin set.
The miler goes On Duty, works the stops one by one in order, and at each stop does either a pickup or a delivery or both :
- Pickup — collects the parcel from the customer or store, photographs it, takes payment if money is owed (cash or UPI/QR), confirms it as picked up.
- Delivery — hands over a parcel carried from the hub, verifies with an OTP and a photo, confirms it as delivered. Deliveries are prepaid, so no money is collected.
He can skip a stop when a store is closed or a customer isn't available, and come back to it later. He cannot reorder the route — the sequence is fixed by the admin. After the last stop he returns to the same hub he started from, bringing back everything he collected plus anything he couldn't deliver.
In short: a first-mile + last-mile round trip, Hub → stop 1 → stop 2 → … → stop N → back to the same Hub, much like an Amazon or Flipkart delivery associate
runs a route. This app is the miler's companion for the whole journey.
Doormile's two apps
| App | Who uses it | What they do |
|---|---|---|
| Doormile customer app | End customers | Book a time slot for a pickup or delivery |
| Miler app (this one) | The rider | Carry out the assigned route |
Who this app is for
One person, on a bike, working through an assigned list of stops for a shift. That is the entire user base. Every design decision in this document traces back to that single user and one fact about him: the daily loop repeats 30–50 times a day.
2. The people and the workflow
| Role | What they do | Which app |
|---|---|---|
| Customer (CX) | Books a time slot for a pickup or delivery | Doormile customer app |
| Hub manager / Admin | Bundles a slot's bookings into an ordered route and assigns it to a miler | Admin / hub system |
| Miler (rider) | Runs the route — pickups and deliveries — and returns to the hub | This app |
The loop, step by step
- Slot booking — customers book time slots in the customer app.
- Route assignment — for a slot, the hub admin groups the bookings and assigns them to one miler as a fixed-order route (Stop 1 … Stop N).
- Route appears — the ordered stops show up in this app.
- On Duty — the miler goes online to start working.
- Work each stop in order
- Pickup stop → navigate → collect parcel → photo proof (+ weight) → take payment if owed (Cash / UPI-QR) → confirm Picked up.
- Delivery stop → navigate → hand over parcel → verify OTP + photo → confirm Delivered (prepaid, no payment).
- Skip if needed — a stop can be skipped and resumed later. The order of the remaining stops never changes.
- Move to next stop — after each success the app prompts the next stop.
- Return to hub — after the last stop, back to the same hub with all collected parcels and any undelivered (RTO) items.
Hard rules
- One route per slot, assigned by the admin.
- Stops are strictly ordered — the miler follows the sequence and cannot reorder it.
- Skip is allowed and normal — not a failure; the stop can be resumed.
- Round trip — always starts and ends at the same hub.
- Pickup vs Delivery — each stop is one or the other; the app shows the type clearly and runs the right flow.
Vocabulary
Rider-facing language is deliberately narrow. Route (the whole assignment) → Stops (ordered, each tagged Pickup or Delivery) → Hub (start and return point). Avoid "booking", "task", and "order" in UI copy — they were used interchangeably in the old build and meant nothing specific to a rider.
3. The routing problem underneath
A miler's day is one solved Vehicle Routing Problem instance. The hub admin solves it; the rider executes it. He is explicitly not allowed to re-optimise — he can't reorder stops — but he absolutely needs to see the shape of the solution he has been handed, because every classic VRP constraint has a physical consequence on the bike.
This is the intellectual backbone of the Home screen redesign. Each constraint maps to something concrete:
| VRP concept | What it means to a miler | Where it appears |
|---|---|---|
| Depot / round trip | The route starts and ends at the same hub, so the return leg is real distance he has to ride | hub → progress → hub rail + ≈ km round trip |
| Capacity (CVRP) | Total parcels and weight must fit in the box. Discovering an overload at stop 9 is too late | Parcels · weight kg |
| Time windows (VRPTW) | Each booking has a customer slot. Arriving after it closes is a failed stop | "N stops past their booked slot" / "close within 30 min" |
| Route duration limit | The shift is the hard cap on the route. Time left ÷ stops left is the pace he is actually racing | Shift countdown + "only X per stop left" |
| Fixed sequence | The admin's order is the solution; re-sorting it would break it | The list never reorders; skip-not-reorder |
| Service time | Each stop takes real minutes (photo, weight, payment, OTP) | Folded into the per-stop budget |
| Cash on route | Not a textbook VRP term, but he is carrying it and is accountable for it | ₹ to collect |
All of this is derived from data the app already has — stop coordinates,
quantities, expected_pickup_time, the shift window in prefs. No new endpoint was
required. The computation lives in lib/views/Dashboard/home/route_brief.dart as
a pure, unit-tested RouteBrief value object.
Two honesty constraints in that code:
- Distance is straight-line haversine, so it under-reads real road distance.
It is always rendered with a
≈and never presented as a promise. - Accepted stops have left the Home list for the Bookings tab, but they are still
on the route — their parcels still need collecting, their kilometres still need
riding.
RouteBrief.from(extraStops: …)pulls them back into the totals so the brief never understates the day.
Reference reading: FarEye — Vehicle Routing Problem, ArcGIS Pro — Solve Vehicle Routing Problem.
4. Architecture
Stack
| Concern | Choice |
|---|---|
| Framework | Flutter (Dart SDK ^3.9.2) |
| State / DI / routing | GetX (GetMaterialApp, Get.put, Get.offAll) + a little provider |
| Responsive sizing | flutter_screenutil, design size 390 × 844 — .w .h .sp .r everywhere |
| Persistence | shared_preferences (session, duty state, shift window, last GPS fix, accepted stops) |
| Maps & nav | google_maps_flutter, flutter_polyline_points, geolocator, geocoding |
| Push | Firebase Core + Messaging + flutter_local_notifications |
| Live tracking | MQTT (mqtt_client) + flutter_foreground_task |
| Media | image_picker (proof photos), minio (uploads), qr_flutter (UPI QR) |
| Motion | lottie, shimmer, custom primitives in app_widgets.dart |
Directory map
lib/
├── main.dart App bootstrap, Firebase, GetMaterialApp, lifecycle
├── helpers/
│ ├── app_bootstrap.dart Decides the opening screen (replaced the splash)
│ ├── shift_end_alarm.dart Android AlarmManager via MethodChannel doormile/shift_end
│ └── http_overrides.dart
├── data/
│ ├── api_config.dart THE backend switch + legacy/v1 adapter
│ ├── accepted_store.dart Locally persisted accepted stops
│ ├── assignment_lookup.dart bookingid → bookingassignmentid resolver
│ └── mock_bookings.dart Demo data (gated behind !useNewApi)
├── Models/
│ └── stop_status.dart Canonical StopStatus enum + raw-string normaliser
├── controllers/ GetX controllers (auth, duty, pickups, summary, …)
├── providers/ HTTP layer, one per domain
├── background/ Foreground service, live tracking, background logs
├── utils/ Kalman filter, MQTT service, device info
└── views/
├── introscreens/ First-run intro
├── onboardscreens/ Sign in, OTP, MPIN
├── Dashboard/
│ ├── home/ Home + route_brief.dart
│ ├── pickups/ The whole per-stop flow (see §7)
│ ├── summary/ Earnings
│ └── profile/ Account, support, rewards, FAQ
├── helpers/constants/ Colors, fonts, spacing, theme
├── helpers/widgets/ Shared kit: app_widgets.dart, miler_app_bar.dart
└── offline/ Full-screen offline state
App entry
main.dart initialises Firebase, registers permanent controllers
(ProfileController, RiderLogController, PickupController, LogController),
starts logging, checks whether the shift already ended while the app was closed,
and runs GetMaterialApp with AppBootstrap as home.
AppBootstrap (lib/helpers/app_bootstrap.dart) decides the opening screen
and nothing else:
- app version changed since last run → force re-verification (MPIN if the rider was signed in, otherwise Sign In)
- signed in + on duty → dashboard
- signed in + off duty → the go-on-duty banner
- signed out, intro already seen → Sign In
- first ever launch → Introscreen
This replaced a four-second animated splash screen. That splash held two jobs: a brand animation and these routing rules. Only the rules were load-bearing. The animation was pure cost for someone opening this app 30–50 times a shift, layered on top of the native launch image that already covers cold start. The routing moved across intact; the four seconds are gone.
Shell
BottomPage (lib/widget/Bottom_page.dart) is a floating pill nav over an
IndexedStack — four tabs, state preserved, no page ever mounted twice:
| Tab | Screen | Purpose |
|---|---|---|
| Home | Homepage |
Greeting, duty toggle, route brief, new stops |
| Bookings | MyPickups |
The active route — ordered stops, hub → progress → hub |
| Earnings | Summary |
Pickup counts, success rate, reward points, distance |
| Account | ProfilePage |
Rider details, support, settings |
Tab switches run a single 280 ms fade + rise controller. Pushed pages use
Transition.cupertino at 300 ms globally.
Background and location
Location is the app's most safety-critical data path, so it gets real treatment:
MilerKalmanFilter(lib/utils/kalman_filter.dart) — a 4-D Kalman filter ([lat, lng, v_lat, v_lng]) smoothing raw GPS. Phone GPS on a moving bike is noisy; unsmoothed fixes produce jumping breadcrumbs and inflated distances, which matters because distance feeds rider payout.LiveTrackingService— position stream → Kalman → MQTT publish, with battery level attached.foreground_service.dart— keeps logging alive with the screen off viaflutter_foreground_taskin its own isolate.ShiftEndAlarm— AndroidAlarmManagerthrough adoormile/shift_endMethodChannel, so the shift-end break log fires even if the app is killed. This is why "Shift not set" is treated as a genuine fault, not a cosmetic gap: with no end time there is no alarm.
Duty state
DutyController is the single source of truth for "is the rider online?".
Duty state used to be re-derived from raw prefs in a dozen places, and the server
onduty int is clobbered by a flaky rider-log sync — which auto-kicked riders
offline after a pickup. The controller fixes the priority in one place:
onduty == 1→ a fresh server shift is authoritative- explicit
onlineflag → the rider's own toggle, which survives the flaky sync onduty == 0→ offline- otherwise → default online
Similarly, StopStatus (lib/Models/stop_status.dart) normalises the backend's
free-form orderstatus string. It used to be compared as raw literals in ~90
places with case-sensitivity landmines ('active' vs 'ACTIVE') and variant
spellings ('picked' / 'picked up' / 'pickuped'). One enum, one parser, and
every filter in the app now agrees.
5. Design principles
These are not aspirational. Each one was derived from a specific failure in the old build and is enforced in the code today.
1. The 30–50× rule
The daily loop repeats 30–50 times a day. Any confusion or extra tap becomes real frustration multiplied many times over. This is the lens for every trade-off: a one-second delay is not one second, it is a minute a day; a redundant tap is not one tap, it is fifty.
Consequence: the splash animation was deleted. Slide gestures were replaced with taps everywhere except money.
2. One screen, one primary action
The rider is standing at a gate with a parcel under one arm. There is exactly one thing he wants to do next. That thing gets the biggest, highest-contrast control; everything else recedes.
Consequence: the confirm sheet's hidden "swipe to update status" became three visible tap buttons — one primary (Picked up / Delivered), two secondary (Skip, Cancel). The Home stop card was cut down to one identity and one primary action.
3. Gestures are earned, not default
A slide is a deliberate friction device. It belongs only where an accidental tap would be expensive. Everything else is a tap.
Consequence: slides remain only on payment confirmation and going Off Duty. Start-pickup, arrived, and status confirm are all taps now.
4. Alert only when actionable; stay silent when healthy
If a coloured banner appears when nothing is wrong, riders learn to ignore all coloured banners. Warnings must be rare enough to still mean something.
Consequence: the route brief's risk lines render only when there is risk. A healthy route is a short, calm card. "Shift set" is a quiet grey strip; "Shift not set" is a full warning surface with a pulsing icon, the consequence spelled out, and a Retry — because an alert with no way out is just noise.
5. Never move things under the finger
The Bookings list polls every few seconds. Re-sorting a list while a thumb is descending on it is how riders tap the wrong stop.
Consequence: polls don't reorder the visible list mid-interaction. Reveal
animations are keyed per logical item and play once, in initState, so a
3-second poll never replays them.
6. Readable at arm's length, in sunlight, with gloves
Minimum font sizes were raised (the old build went down to 7sp). Touch targets have a 44 px floor. Contrast is checked against a white surface in daylight, not against a designer's dark monitor.
7. Colour carries meaning, never decoration
See §6. Green means go. Maroon means brand or pickup. Blue means delivery. Amber means warning or skip. Red means danger. A colour never appears for variety.
8. Motion is feedback, not garnish
Every animation answers a question: did my tap register (PressScale), is this
new (Reveal), is something loading (shimmer skeletons), did I change tabs
(the nav fade + rise). Nothing animates just to look expensive.
9. Degrade gracefully, log loudly
The backend has real gaps (§8). The app never blocks on a missing endpoint — it
falls back, logs [API_GAP], and keeps the rider moving. A rider stuck at a gate
because a summary endpoint is missing is an unacceptable failure mode.
10. Honest numbers
If a number is an estimate, it is rendered as one. Straight-line distance shows
≈. Missing data renders —, never 0. A metric that can't include accepted
stops doesn't get called "Stops left" until it does.
6. The design system
Reuse the tokens. Do not hardcode new shades or sizes.
Colour — lib/views/helpers/constants/Colorconstants.dart
Semantic roles (agreed; use these names, not raw hex):
| Role | Token | Value |
|---|---|---|
| Brand / pickup accent | ColorConstants.primary |
#960019 maroon |
| Go / success / positive completion | ColorConstants.acceptGreen |
#12B76A |
| Delivery / info accent | (in stop_type.dart) |
#2563EB blue |
| Danger / cancel | ColorConstants.errorRed |
#DC2626 |
| Warning / skip | ColorConstants.warning |
#B45309 amber |
The green was consolidated from four competing values (#16A34A, #34C759,
#2E7D32, #4CAF50) that were scattered across Accept, Picked up, Delivered,
Confirm, payment dialogs, verify success, and the done screen. They are now one
token. A few #007AFF and #BA1A1A strays remain in nav and summary.
The full palette is a Material-3 role set — surface*, onSurface*, outline*,
primary/onPrimary/primaryContainer, error*, warning*, success* — so
new surfaces have a correct token without inventing one.
Typography — Font_constant.dart
Manrope, bundled as the OFL variable font (assets/fonts/Manrope/Manrope-VF.ttf).
It is the free, legal stand-in for Uber Move, which is proprietary and cannot be
shipped. Fallback is Plus Jakarta Sans.
Every text style in the app routes through FontConstants.fontFamily — there are
no hardcoded family strings in lib/, so a single constant swaps the entire app's
typeface. Scale runs displayLg 32 → headlineMd 24 → headlineSm 20 →
bodyLg 18 → bodyMd 16 → labelBold 14 → labelSm 12.
Spacing, radius, shadow — design_constants.dart
4 px base scale (spacingXs 2 … spacing5xl 40), radius scale
(radiusXs 2 … radius2xl 20, radiusFull 999), and five shadow levels
(shadowXs … shadowXl).
Shared components — views/helpers/widgets/
| Component | File | Use |
|---|---|---|
AppCard |
app_widgets.dart |
White rounded card with system shadow |
PrimaryButton |
app_widgets.dart |
Solid maroon CTA |
Reveal |
app_widgets.dart |
One-shot fade + rise; keyed, plays once |
staggerDelay(index) |
app_widgets.dart |
Staggered list entrance |
PressScale |
app_widgets.dart |
Press feedback for custom tappables |
SkeletonBone / MilerShimmer / skeletonCard() / SkeletonList |
app_widgets.dart |
Shimmer loading, replaces bare spinners |
MilerAppBar |
miler_app_bar.dart |
The one top bar — logo, title, subtitle, trailing, 60 px, hairline |
RouteBrief / RouteBriefCard / ShiftBanner |
home/route_brief.dart |
VRP route summary + shift alert |
stopKindOf / StopKindUi |
pickups/stop_type.dart |
Pickup vs delivery branch — accent, icon, labels, verbs |
Do not hand-roll an AppBar. Use MilerAppBar. Home is the one exception — it
carries a richer dashboard header — and its logo and typography are kept aligned
to the shared bar by hand.
Auth screen pattern
All onboarding screens follow one shape: top-anchored content (heading and
input in the upper third, never vertically centred — this fixed the "input box too
low" complaint), a large left-aligned heading (26sp, w700, -0.6 tracking), a
one-line helper (14sp, height 1.45), and a pinned footer with a single
high-contrast CTA — solid maroon fill, white text, w700, radius 14, 56.h, disabled
at alpha .35. Structure: top Column → Expanded(SingleChildScrollView) → pinned
footer. Mirror this for any new auth screen.
7. Screen by screen — what was designed and why
Home — views/Dashboard/home/homepage.dart
The rider's staging area: who he is, whether he's on duty, what the day looks like, and what's new.
Header. The brand mark sits at 56 px on a bordered, brand-tinted tile. At its
old 46 px, flat on a white header, it read as a smudge rather than a logo — the
tile gives it an edge. Next to it, "Hi, {name}" is the heaviest type in the
header (17sp / w800, near-black), with Miler · {city} beneath at 11.5sp / w600
secondary. The greeting is the anchor of the screen, so it gets the weight; the
location is context, so it recedes. On the right, the duty toggle.
Shift banner. Two deliberately different states. Set is a quiet grey strip with the window and time remaining — confirmation, not news. Not set is a warning surface with a left accent bar, a pulsing icon, the consequence stated plainly ("duty hours and the shift-end reminder are off"), and a Retry. Without a shift window the app cannot close duty and cannot fire the shift-end alarm, so this is an operational fault. Retry re-reads prefs and says so honestly when the fix is off-app — there is no standalone endpoint to re-pull the shift, only the login / MPIN profile sync.
Route brief. The VRP card described in §3 — a hub → progress → hub rail
showing X of Y stops done, then stops left · parcels + weight · ≈ km round trip · ₹ to collect, then risk lines only when there is risk.
Stop cards. Redesigned Uber-style: PICKUP/DELIVERY badge + order id + circular
select in the header, a hero name (16.5sp w700, -0.3 tracking), pin + address, a
grey items chip, and labelled Call / Details buttons. Removed along the way: a
misleading two-dot route timeline (a single stop is not a route — now a compact
location badge), and a redundant second identity row.
Bookings / My Pickups — views/Dashboard/pickups/pickups.dart
The active route: the ordered list of stops with a hub → progress → hub overview.
Staggered Reveal on entry, keyed per stop so the poll never replays it.
The per-stop flow — views/Dashboard/pickups/
| File | Role |
|---|---|
card.dart |
The stop card in the route list |
map.dart |
Map preview, navigation launch, and the owner of the success-screen transition |
multi_map.dart |
Whole-route map with real step numbers |
map_btn.dart |
The map-view entry row on the stop card |
pip.dart |
Picture-in-picture info card while navigating |
pickup_verify.dart |
Proof capture — photo, weight, OTP, signature plumbing |
sheet.dart |
The confirm bottom sheet |
skip_sheet.dart |
Skip with reason |
payment_screen.dart |
Cash / UPI-QR collection |
done.dart |
Success screen + "move to next stop" |
Critical navigation architecture — do not regress this. The confirm sheet
(_PickupBottomSheet._handleConfirm) must not navigate to PickupsDone
itself. Pushing a full page from inside a modal bottom sheet is unreliable — the
success screen silently never showed, and an isCurrent race then popped
everything. Instead the sheet pops with an outcome map:
{'outcome': 'completed'|'cancelled', 'isDelivery': bool,
'bonusPoints': int, 'paymentMethod': String}
// skip pops `true`; dismiss pops `null`
The caller — map.dart's _startPickupNavigation, which is a normal page
route — inspects the result and does
Navigator.pushReplacement(context, PickupsDone(...)) from its own route context
(map.dart:788). It also removes the finished stop from the list first, so it
doesn't linger as "active" blocking the next stop. This is what makes "Move to
next stop" appear after picked up / delivered / cancelled.
Other flow decisions: no-payment pickups skip the payment-method list entirely
(the reason list only shows for cancel or pickup-with-collection). The signature
step was dropped for pickups — photo plus weight is enough proof. Test pre-fills
in pickup_verify.dart were removed.
Earnings — views/Dashboard/summary/summary.dart
Pickup counts, success rate, reward points, distance ridden. The hero count is
wrapped in an AnimatedSwitcher (fade + scale) so switching period animates
rather than cutting.
Account — views/Dashboard/profile/
Rider details, support tickets, FAQ, notifications, alert sound, saved addresses,
rewards, terms. Page-entrance Reveal; PressScale on the tile rows.
Offline — views/offline/offline_page.dart
A full-screen state with a breathing halo, stacked over the app whenever
connectivity drops. Retry routes back through AppBootstrap, which re-decides
login vs. home from scratch.
8. Backend integration and known gaps
The app targets the new v1 backend — https://api.doormile.com/api/v1,
Bearer-token auth, {success, data, message} envelopes, camelCase — alongside the
legacy jupiter/workolik backend.
Strategy: adapter + env flag. lib/data/api_config.dart is the one switch and
mapper:
ApiConfig.useNewApi— defaults true; override with--dart-define=USE_NEW_API=falseto go back to the old backend and mock data.ApiConfig.toLegacyEnvelope()re-wraps v1 responses into the legacy{status, details}shape with legacy field names.- Providers branch on the flag and call the new endpoints; the ~90 UI files are untouched.
Two mock layers had to be disabled for live data: getMockQueues() /
getMockBookingPageItems() fallbacks (now gated behind !useNewApi), and the
auth controller, which used to fake a Demo Rider (userid 9999) and bypass the
server entirely. In live mode verifyPinWithServer now does a real
POST /miler/verify-pin and succeeds only on a server OK plus a stored token.
The pasted API doc was inaccurate (verified live 2026-07-18). verify-pin
returns the login under user / user.profile, not data. The mapper reads
the real shape. Booking field names are still based on the unreliable doc —
re-verify the pickupFromBooking mapping once a real booking is assigned.
AssignmentLookup (lib/data/assignment_lookup.dart) exists because of one of
these mismatches: POST /miler/assignments/{id}/accept keys on
bookingassignmentid, but everything the app renders comes from
GET /miler/bookings, whose rows only carry bookingid. Different sequences,
different values. Sending a booking id made the backend answer 404 while the rider
saw success and the booking was never actually accepted. GET /miler/assignments
is the only place the pairing is exposed, so the app fetches it and keeps a
short-lived map.
Known backend gaps
The app degrades gracefully around all of these and logs [API_GAP]:
- no skip / resume endpoint
- no booking-cancel endpoint
- no per-stop
typefield (pickup vs delivery) and nostepordering - booking carries no
assignmentid/consignmentid— accept, reject and deliver assume they equal the booking id - no COD amount on the booking
- no update-PIN or rider-count endpoints
- no bonus-summary endpoint (rewards are derived from
/miler/earnings)
Release safety
Demo and test toggles are tied to kDebugMode so they auto-disable in release
builds: kBypassGeofenceForTesting (pickups_controller.dart), kDemoMixedRoute
(stop_type.dart), and the summary weekly-kms mock fallback.
9. What we are doing with this app now
The programme is one thing: make the app simple enough that a rider running it 30–50 times a day never has to think about it. The app was already well-built — branded app bars, a coherent maroon palette, a premium Earnings hero, clean cards. It did not need a layout rewrite. It needed clarity, motion, and honest data.
Done
- Terminology and IA — one noun set (Route / Stops / Hub). Home = new stops and route start; Bookings = the active route.
- Typeface — Plus Jakarta Sans → Manrope, verified on device.
- Semantic colour consolidation — four greens → one
acceptGreen; roles documented and applied. - Slide fatigue killed — start-pickup and arrived became taps; the confirm sheet's hidden swipe became three visible buttons. Slides remain only on payment and Off Duty.
- Motion layer —
Reveal,PressScale, shimmer skeletons; staggered lists on Bookings, Home and notifications;Transition.cupertinoglobally; animated tab switches over a state-preservingIndexedStack. - Auth screens — one top-anchored pattern with a pinned solid-maroon CTA; logo shrunk 180 → 104.h so content sits higher.
- Mixed pickup/delivery plumbing —
stop_type.dartgives one branch point; the delivery flow is fully wired end to end (card badge, OTP + photo verify, no-payment "Delivered", done screen) behindkDemoMixedRoute. - Duty and status normalisation —
DutyControllerandStopStatusended the "rider randomly goes offline" and "status string mismatch" classes of bug. - Splash screen deleted — routing preserved in
AppBootstrap, four seconds per launch removed. - Home redesign — bigger, framed logo; bold greeting; shift-not-set promoted
to a real alert with a Retry; and the VRP route brief (§3), covered by 15
unit tests in
test/route_brief_test.dart.
Next
- Ship mixed routes for real. Everything on the app side is built. It is
blocked on the backend sending a per-stop
type(pickup|delivery) and asteporder. The moment it does,stopKindOfprefers it automatically andkDemoMixedRoutebecomes irrelevant. - Re-verify
pickupFromBookingagainst a real assigned booking (§8). - Retire the accept/reject metaphor on Home. This is not an on-demand marketplace — the route is pre-assigned, so multi-select checkboxes plus "Select All" plus single-tap accept is three interaction models for a decision the rider doesn't actually get to make. Reframe as start assigned route.
- Make skip/resume first-class in the route view, and get a real backend endpoint behind it.
- Close the remaining colour strays (
#007AFF,#BA1A1A) in nav and summary. - Real road distance in the route brief once a routing/matrix API is
available — the
≈haversine is a deliberate placeholder.
10. Conventions for anyone touching this code
- Read §7's navigation warning before touching the pickup flow. The sheet-pops-an-outcome architecture is load-bearing and was arrived at after a silent-failure bug.
- Use the tokens.
ColorConstants,DesignConstants,FontConstants. No new hex values, no hardcoded font families, no magic spacing. - Use the shared components.
MilerAppBarfor headers,AppCardfor cards,PrimaryButtonfor CTAs,Reveal/PressScale/skeletons for motion and loading. Hand-rolling one of these is how the app got four different greens. - Size with ScreenUtil.
.w.h.sp.ragainst the 390 × 844 design size. Use.w(not.sp) for anything that must not resize with the user's font setting — logos, icon frames. - New gestures are taps. A slide needs a justification: it is only for actions that would be expensive to trigger by accident.
- Keep
Revealkeyed. An unkeyedRevealin a polled list replays every few seconds and looks broken. - Never block the rider on a missing endpoint. Fall back, log
[API_GAP], keep him moving. - Render honesty.
≈for estimates,—for missing, never a fake0. - Run
flutter analyze lib/ test/before you call it done. The target is zero errors; the existing infos are legacy noise, don't add to them. - Test the pure logic.
RouteBriefis a value object with no Flutter dependency precisely so it can be unit-tested. Follow that pattern for new derived data.
The core idea, simply put
A miler is handed a fixed, ordered route for a time slot, does every pickup and delivery on it in sequence, and brings everything back to the hub he started from — and this app guides him through every stop of that journey.
Everything else in this document is in service of making that loop cost him as little attention as possible, fifty times a day.