Compare commits

9 Commits

Author SHA1 Message Date
33607a4c63 Drop READ_MEDIA_IMAGES, bump to 1.2.35+151
Play's photo and video permissions policy refused version 150 over this, and
was right to. The policy allows the permission only where a system picker is
"technically insufficient to provide core app functionality", and the app's
real need is the opposite of that:

  * every operational photograph opens the camera directly — parcel shots at
    pickup, proof of delivery at the door, stop verification. None read the
    library, deliberately: a picture chosen from the gallery could have been
    taken anywhere at any time and would not be evidence that a parcel was
    collected or delivered;
  * the one gallery read in the whole app is a rider setting their profile
    photograph, once, at sign-up.

That is the textbook "one-time or infrequent" case the policy points at the
Android photo picker for. image_picker 1.1.2 already routes
ImageSource.gallery through that system picker on Android 13+ when the
permission is absent, so the profile picker keeps working and needs no
permission at all.

READ_EXTERNAL_STORAGE stays, still capped at maxSdkVersion 32, for devices
older than the photo picker.

Verified absent from the built bundle's merged manifest rather than only from
the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-19 15:22:58 +05:30
f975344c7d Rename package to com.doormile.miler, bump to 1.2.30+146
Play rejected the upload: the listing expects com.doormile.miler and the build
carried com.doormile.partner. This is a new app rather than an update — a
package rename gives Play a different app, so nothing carries over from the old
listing and versionCode restarts against an empty history.

applicationId moves; `namespace` and the Kotlin `package com.doormile.partner`
declarations deliberately do not. Those are the code's own package and are
allowed to differ from the application id — renaming them would mean moving
source directories to change a string nothing outside the build reads.

Four things did have to follow the id, because each of them names the app to
something outside it:

  * the SHIFT_END_ALARM broadcast action, or two builds installed side by side
    would answer each other's shift alarms;
  * the update checker's androidId in main.dart and UpdateScreen.dart, which
    looks the app up ON PLAY — left stale it would poll a different listing and
    report a rider up to date when he is not;
  * the Play URL the update screen sends him to, which would have opened the
    store page for an app he does not have;
  * the map tile and routing user-agents, which identify this app to OSM and
    OSRM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 16:31:16 +05:30
940f080f29 Bump to 1.2.29+145
versionCode 144 is the build already on Play; a duplicate is rejected at
upload. pubspec is the source of truth — android/local.properties is
gitignored and regenerated from it by `flutter build`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:07:30 +05:30
d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30
127fa062ed miler map 2026-09-09 12:55:23 +05:30
074cc0eccf updated the sheet 2026-08-28 18:16:28 +05:30
69f4f3e909 device infos 2026-08-28 15:07:30 +05:30
5723d373b2 production 2026-08-28 11:13:15 +05:30
d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30
493 changed files with 106965 additions and 16587 deletions

115
.gitignore vendored
View File

@@ -1,45 +1,70 @@
# Miscellaneous # Miscellaneous
*.class *.class
*.log *.log
*.pyc *.pyc
*.swp *.swp
.DS_Store .DS_Store
.atom/ .atom/
.build/ .build/
.buildlog/ .buildlog/
.history .history
.svn/ .svn/
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
# IntelliJ related # IntelliJ related
*.iml *.iml
*.ipr *.ipr
*.iws *.iws
.idea/ .idea/
# The .vscode folder contains launch configuration and tasks you configure in # The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line
# is commented out by default. # is commented out by default.
#.vscode/ #.vscode/
# Flutter/Dart/Pub related # Flutter/Dart/Pub related
**/doc/api/ **/doc/api/
**/ios/Flutter/.last_build_id **/ios/Flutter/.last_build_id
.dart_tool/ .dart_tool/
.flutter-plugins-dependencies .flutter-plugins-dependencies
.pub-cache/ .pub-cache/
.pub/ .pub/
/build/ /build/
/coverage/ /coverage/
# Symbolication related # Symbolication related
app.*.symbols app.*.symbols
# Obfuscation related # Obfuscation related
app.*.map.json app.*.map.json
# Android Studio will place build artifacts here # Android Studio will place build artifacts here
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Golden-comparison debris. `flutter test` writes four PNGs per failing shot
# (isolatedDiff / maskedDiff / masterImage / testImage) and regenerates them on
# every failing run — 128 of them were sitting untracked. The references
# themselves live in test/shots and ARE committed; these are the diff output.
test/failures/
# ── Signing material never goes in the repo ──
#
# `android/key.properties` holds the keystore passwords in plain text and the
# `.jks` files are the signing keys themselves. Anyone with both can publish an
# update to the app under its own identity, so a copy in git is a copy in every
# clone, every fork and every CI cache, for as long as the history exists.
#
# The keys that were already committed (nearlerider-keystore.jks,
# release-key.jks) are unused — the app signs with a key generated outside the
# repo — but they are still in the history and should be treated as public.
# They are left tracked deliberately: removing them from the working tree does
# not remove them from history, and rewriting history is the repo owner's call.
#
# Keep the keystore and its passwords in a password manager or a CI secret
# store. Losing them means never being able to update the app on Play again.
android/key.properties
*.jks
*.keystore

File diff suppressed because it is too large Load Diff

View File

@@ -1,400 +0,0 @@
# Miler (Rider App) — Backend API Specification
**Audience:** Doormile backend team
**Purpose:** This is the contract the **Miler rider app** needs the backend to implement so we can connect everything and go live. It documents (1) what the app already sends and expects today, and (2) the changes required to ship the mixed pickup + delivery route feature.
**How to use this doc:** Design/confirm each endpoint below, then send back the finalized doc (exact URLs, request/response JSON, and any field renames). We then wire the app to your final contract and go live.
> Status legend for each endpoint:
> **[LIVE]** already called by the current app — keep the contract stable.
> **[CHANGE]** needs a change/addition before go-live.
> **[NEW]** not built yet.
---
## 1. Global conventions
### 1.1 Base URLs / environments
The app switches between **dev** and **live** by an environment flag. Please expose the same path on both:
```
DEV base: https://jupiter.doormile.app/dev/api
LIVE base: https://jupiter.doormile.app/live/api
```
> **⚠️ Must fix before go-live:** Today a few write endpoints (update pickup, create rider log, break logs) point at a **second host** `https://queue.workolik.com/live/api/...`, and the app currently has to **bypass TLS cert validation and hard-code the server IP** (`66.116.225.226`) because carrier DNS returns broken CDN nodes for that host and the certificate doesn't validate. **Please serve everything from one host (`jupiter.doormile.app`) with a valid TLS certificate and correct DNS** so we can remove the SSL-bypass hack. This is a security and reliability blocker.
### 1.2 Auth
- The app does **not** currently send a bearer token — requests are keyed by `userid`. **Please tell us the intended auth model.** Recommended: return a JWT/session token from login and require `Authorization: Bearer <token>` on every other call. If you keep `userid`-only, confirm that explicitly.
### 1.3 Request/response format
- Content type: `application/json` (both directions).
- **Standard response envelope** (already used by read endpoints — please use it everywhere):
```json
{
"code": 200,
"status": true,
"message": "Success",
"details": [ ... ] // object OR array — the actual payload
}
```
- The app reads the payload from `details` first, then falls back to `data`, then the root. **Please standardize on `details`.**
- On error return `status: false`, a non-2xx HTTP code, and a human-readable `message`.
### 1.4 Formats
- **Dates (query params):** `YYYY-MM-DD` (e.g. `2026-07-18`).
- **Timestamps (bodies):** full date-time string, currently `YYYY-MM-DD HH:mm:ss`. Confirm timezone — please use **IST** consistently and state it.
- **Lat/Long:** strings, decimal degrees (e.g. `"12.9716"`). Do not truncate precision.
- **Money:** number (₹). Confirm 2-decimal.
- **Booleans / flags:** confirm whether you use `true/false` or `1/0` — the app currently tolerates both for `status` but please pick one.
### 1.5 Cache-busting
Read endpoints receive a `t=<epoch-millis>` query param — ignore it server-side; it exists to defeat caching.
---
## 2. Authentication
### 2.1 Rider Login **[LIVE]**
`POST /v2/users/rider/login`
Request:
```json
{
"contactno": "9876543210",
"devicetype": "android", // "android" | "ios"
"configid": 123,
"deviceid": "<device-uuid>",
"userfcmtoken": "<fcm-token>",
"pin": 1234 // optional; sent on PIN login
}
```
Response `details` (object) — **every field below is consumed by the app**, so keep them:
```json
{
"userid": 1001,
"riderid": 55,
"partnerid": 12,
"configid": 123,
"shiftid": 7,
"logid": 0,
"logseconds": 0,
"tenantid": 3,
"locationid": 9,
"applocationid": 9,
"roleid": 2,
"authmode": 1,
"authname": "…",
"firstname": "Suriya",
"lastname": "K",
"username": "suriya",
"email": "…",
"onduty": 0, // 0 = off duty, 1 = on duty
"starttime": "09:00", // shift window (display)
"endtime": "18:00",
"pickupradius": 100, // meters — geofence radius for arrived/pickup
"fuelcharge": 5.0, // ₹ per km (rider payout)
"firstmilecharge": 0.0, // per-km first-mile charge (alias: firstmilecharges)
"userfcmtoken": "<echoed>"
}
```
Notes:
- `authmode` decides the flow (e.g. whether a PIN step is required). **Please document the possible values.**
- If a rider needs to set a PIN on first login, tell us how that state is signaled.
### 2.2 Update PIN **[LIVE]**
`PUT /v2/users/update`
```json
{ "userid": 1001, "pin": 1234 }
```
Response: standard envelope, `status: true` on success.
---
## 3. Rider duty log (On/Off Duty, breaks)
The rider goes **On Duty** → works stops → **Off Duty**. These calls power the duty timer and location tracking.
### 3.1 Create Rider Log (go On Duty) **[LIVE]**
`POST /v2/partners/createriderlog`
Request:
```json
{
"logid": 0, // 0 → server assigns new logid; returned in response
"userid": 1001,
"partnerid": 12,
"shiftid": 7,
"logdate": "2026-07-18T09:00:00",
"login": "2026-07-18 09:00:00", // on-duty timestamp
"onduty": 1,
"status": "online",
"latitude": "12.9716",
"longitude": "77.5946",
"raw_latitude": "12.9716",
"raw_longitude": "77.5946",
"velocity_lat": "0",
"velocity_lng": "0",
"speed": "0",
"heading": "0",
"contactno": "9876543210",
"tenantid": 3,
"locationid": 9,
"applocationid": 9,
"userfcmtoken": "<fcm-token>",
"orderid": "" // optional; current stop context if any
}
```
**Response must return the new `logid`** (the app stores it and uses it for updates). Return it in `details`.
### 3.2 Update Rider Log (heartbeat / go Off Duty) **[LIVE]**
`PUT /v1/partners/updateriderlog`
Sent periodically as a location heartbeat and once when going Off Duty:
```json
{
"logid": 4567,
"userid": 1001,
"logdate": "2026-07-18T13:00:00",
"latitude": "12.9722",
"longitude": "77.5950",
"speed": "0",
"heading": "0",
"status": "online", // "offline" when going off duty
"orderid": ""
}
```
Confirm the exact field(s) used to mark **Off Duty** (e.g. `status: "offline"` and/or `logout` timestamp + `onduty: 0`). Please state it explicitly.
### 3.3 Get Rider Log **[LIVE]**
`GET /v1/partners/getriderlog?userid=1001` → current log record (used to restore duty state on app restart). Return `logid`, `onduty`, `login` time, accumulated seconds, etc.
### 3.4 Get Rider Count **[LIVE]**
`GET /v1/partners/getridercount?userid=1001` → counts for the dashboard (e.g. completed stops today). **Please document exact fields.**
### 3.5 Break logs **[LIVE]**
- `POST /v2/partners/createbreaklog` — start break.
- `PUT /v2/partners/updatebreaklog` — end break.
Please document the exact request bodies (rider id, logid, start/end timestamps, break type).
---
## 4. Route & Stops (the core flow)
**Domain recap for the backend:** For a booked time slot, the hub/admin assigns a rider an **ordered route** of stops. The rider starts at a **hub**, works stops **in fixed sequence** (cannot reorder), can **skip and resume** a stop, and after the last stop **returns to the same hub**. Each stop is either a **PICKUP** or a **DELIVERY** (see §4.5 — this is the key new requirement).
### 4.1 Get Pickup Queue (assigned/pending stops) **[LIVE]**
`GET /v2/pickups/getpickupqueues?userid=1001&fromdate=2026-07-18&todate=2026-07-18&orderstatus=<optional>&t=<epoch>`
Returns `details` = **array of stop objects**. Fields the app reads today (please keep these names, lowercase):
| Field | Type | Meaning |
|---|---|---|
| `orderid` | string/int | Order identifier shown to rider |
| `pickupid` | int | **Stop id — primary key for all status updates** |
| `orderheaderid` | int | Order header id (sent back on updates) |
| `pickuplocationid` | int | Location id of the stop |
| `orderstatus` | string | Current status (see §4.6 lifecycle) |
| `step` | int | **Sequence position in the route (1..N) — defines fixed order** |
| `pickupcustomer` | string | Customer / store name |
| `pickupcontactno` | string | Customer phone (Call button) |
| `pickupaddress` | string | Stop address |
| `pickuplat` / `pickuplong` | string | Stop coordinates (geofence + navigation) |
| `dropaddress` | string | Drop address (delivery stops) |
| `droplat` / `droplon` | string | Drop coordinates (delivery stops) |
| `collectionamt` | number | Amount to collect at this stop (0 = none) |
| `pickupamt` | number | Pickup charge |
| `eta` / `expected_pickup_time` | string | ETA / expected time (display) |
| `tenantid` / `tenantname` | int/string | Tenant |
| `starttime` | string | Slot / assignment start |
> **⚠️ Casing:** the app has seen **both** `pickuplat`/`PickupLat` and `orderid`/`OrderId` variants in responses. **Please return one consistent casing (lowercase preferred) across all endpoints** and never mix within a payload.
### 4.2 Get Current Pickups **[LIVE]**
`GET /v1/pickups/getpickups?userid=1001&fromdate=&todate=&t=` — the rider's active/in-progress stops. Same object shape as §4.1.
### 4.3 Get Pickups V3 (date-bounded / picked history) **[LIVE]**
`GET /v3/pickups/getpickups?userid=1001&fromdate=&todate=&t=` — used for completed/"picked" history. Same object shape.
> Please clarify the intended difference between v1/v2/v3 of `getpickups` so we can consolidate. Ideally **one** endpoint filtered by `orderstatus` and date range.
### 4.4 Update Stop status **[LIVE — needs delivery extension, see §4.5]**
`PUT /v1/pickups/updatepickup`
This one endpoint is called at **every** state transition; `orderstatus` selects the transition. Common fields on all transitions:
```json
{
"pickupid": 8890,
"orderheaderid": 4501,
"orderstatus": "<state>",
"riderslat": "12.9716", // rider GPS at the moment
"riderslon": "77.5946",
"raw_latitude": "12.9716", "raw_longitude": "77.5946",
"velocity_lat": "0", "velocity_lng": "0", "speed": "0", "heading": "0",
"notes": ""
}
```
Per-transition additional fields:
**`accepted`** — rider starts the assigned route/stop.
**`active`** — rider en route to the stop.
**`arrived`** — rider reached the stop (passes geofence check):
```json
{ "orderstatus": "arrived", "arrivaltime": "2026-07-18 10:05:00", "pickuplat": "", "pickuplong": "", "actualkms": "", "pickupamt": 0.0 }
```
**`Picked up`** (pickup complete) — the big one:
```json
{
"orderstatus": "Picked up",
"pickupedtime": "2026-07-18 10:07:00",
"pickuptime": "2026-07-18 10:07:00",
"pickuplocationid": 9,
"pickuplat": "12.9716", "pickuplong": "77.5946",
"riderkms": "0.4200", // distance rider travelled to this stop
"ridercharges": 2.10, // payout for this leg
"ridertime": 12, // minutes
"pickupamt": 0.0,
"collectionamt": 100.0, // amount due
"collectedamt": 100.0, // amount actually collected
"collectionstatus": "collected",
"smspickup": 0,
"wasskipped": false, // true if this stop had been skipped earlier
"bonuspts": 5,
"dropimage": "<base64-or-url>" // photo proof of pickup
}
```
**`skipped`** — rider skips this stop, will resume later (first-class; sequence preserved).
**`cancelled`** — pickup could not be completed (reason in `notes`).
**`rejected`** — rider rejects the assigned stop.
**`picked`** — internal "picked" marker (via `updatepickup` v1). Please clarify vs `Picked up`.
> **⚠️ Please normalize `orderstatus` values.** Today they are inconsistent (`"Picked up"` with a space & capital, vs `"arrived"`, `"active"`, `"skipped"` lowercase). **Give us one canonical set of machine values** (e.g. all lowercase snake: `assigned`, `active`, `arrived`, `picked_up`, `delivered`, `skipped`, `cancelled`, `rejected`) and we'll map the UI labels ourselves.
Response for all updates: standard envelope with `status: true`.
### 4.5 ⭐ REQUIRED CHANGE — Stop `type` (Pickup vs Delivery) **[CHANGE]**
**This is the single most important change for go-live.** Each stop on a route can be a **pickup** or a **delivery**, and the app UI must branch on it. Today the API returns no such field, so the app treats **every stop as a pickup**. Please add:
1. **On every stop object** (§4.1–4.3) add a stop type field:
```json
"type": "pickup" // "pickup" | "delivery"
```
(Name it `type` or `stoptype` — the app already looks for `type`/`stoptype`/`stopType`; **pick one and tell us**.)
2. **For `delivery` stops**, the stop object must carry delivery details:
| Field | Type | Meaning |
|---|---|---|
| `dropaddress` | string | Where to deliver |
| `droplat` / `droplon` | string | Delivery coordinates (geofence + nav) |
| `otp` | string/int | Delivery OTP the customer gives (proof of delivery) |
| `collectionamt` | number | COD to collect on delivery (0 = prepaid) |
| customer name/phone | string | Recipient contact (reuse `pickupcustomer`/`pickupcontactno` or give delivery-specific fields — **tell us which**) |
3. **Delivery completion** goes through the same `PUT /v1/pickups/updatepickup` with:
```json
{
"pickupid": 8891,
"orderstatus": "delivered", // canonical value TBD (see §4.4 note)
"deliveredtime": "2026-07-18 10:20:00",
"otp": "4821", // OTP the rider entered — verify server-side
"dropimage": "<base64-or-url>", // photo proof of delivery
"collectedamt": 0.0, "collectionstatus": "prepaid",
"riderslat": "…", "riderslon": "…", "riderkms": "…", "bonuspts": 5
}
```
Please confirm: (a) whether OTP is **verified server-side** (recommended) or just recorded, (b) the canonical `orderstatus` for a completed delivery, (c) delivery-specific proof fields (signature? photo? OTP-only?).
### 4.6 Stop status lifecycle (state machine)
```
assigned ──► active ──► arrived ──► ┌─ (pickup) picked_up ─┐
│ │ │ └─ (delivery) delivered ─┤──► [next stop]
│ │ └────────────► skipped ──► (resume later) ─► active
└────────────┴──────────────────────► cancelled / rejected
```
Rules the backend must enforce/allow:
- Rider **cannot reorder**; `step` is authoritative.
- Rider **can skip** any stop and resume it later — `skipped` is not terminal.
- After the **last** stop the rider returns to the **origin hub**. Please tell us whether hub-return is its own record/status or implicit.
### 4.7 Create Pickup Log **[LIVE]**
`POST /v2/pickups/createpickuplog` — audit/event log entries for a stop. Body is wrapped in an **array**: `[ { ...event } ]`. Please document the event schema (event type, timestamp, pickupid, lat/long).
---
## 5. Earnings / Summary
### 5.1 Partner summary **[LIVE]**
`GET /v2/partners/...` (base `https://jupiter.doormile.app/live/api/v2/partners`)
Powers the Earnings screen (daily/weekly/monthly totals, stop counts, payout). **Please document the exact path + params (userid, period, date range) and the response fields** (totals, per-day breakdown).
### 5.2 Rider weekly KMs **[LIVE]**
`GET /v1/partners/...` — weekly distance for payout. Document exact path + response.
---
## 6. Supporting endpoints
These are used by the app; **please document each** (request + response):
- **Notifications** — list rider notifications (used on the notifications tab). Need: list + mark-read.
- **Rewards / bonus points** — the app shows `bonuspts`/`bonusPoints`; document how points are earned and fetched.
- **Support tickets** — create ticket, list tickets (models exist: subject, description, status, timestamps).
- **FCM push** — confirm the payload schema for push notifications (new stop assigned, route updated, etc.) so we can handle taps/deep-links.
- **App version / force-update** — the app tracks `CurrentVersion`; if you gate minimum version, document the endpoint.
---
## 7. Field glossary (canonical names)
| Field | Meaning |
|---|---|
| `userid` | Rider's user id (primary key the app sends everywhere) |
| `riderid` / `partnerid` | Rider/partner identifiers |
| `shiftid` / `logid` | Shift and current duty-log ids |
| `tenantid` / `locationid` / `applocationid` | Org / hub / app-location scoping |
| `pickupid` | **Stop id** — the PK for a single stop, used on all status updates |
| `orderid` / `orderheaderid` | Order + order-header identifiers |
| `step` | Stop's fixed position in the route (1..N) |
| `type` / `stoptype` | **NEW:** `pickup` \| `delivery` |
| `orderstatus` | Stop state (see §4.6) |
| `collectionamt` / `collectedamt` / `collectionstatus` | COD due / collected / status |
| `pickuplat`,`pickuplong` / `droplat`,`droplon` | Stop / drop coordinates |
| `riderslat`,`riderslon` | Rider GPS at time of action |
| `riderkms` / `ridercharges` / `ridertime` | Distance / payout / minutes for a leg |
| `otp` | Delivery OTP (proof of delivery) |
| `dropimage` | Photo proof (pickup or delivery) |
| `bonuspts` | Bonus points for completing a stop |
| `pickupradius` | Geofence radius (m) for arrived/complete |
---
## 8. Open questions for the backend team (please answer in your returned doc)
1. **Auth model** — token-based or `userid`-only? (§1.2)
2. **Single host + valid TLS + working DNS** — can we drop the `queue.workolik.com` host and the SSL/IP hack? (§1.1)
3. **Canonical `orderstatus` values** — give us the final machine strings. (§4.4)
4. **Stop `type` field** — final field name (`type` vs `stoptype`) and the delivery fields. (§4.5)
5. **Delivery proof** — OTP verified server-side? photo/signature required? canonical `delivered` status? (§4.5)
6. **getpickups v1/v2/v3** — can we consolidate to one endpoint? (§4.3)
7. **Casing** — confirm all-lowercase field names across every endpoint. (§4.1)
8. **Timezone** — confirm IST for all timestamps. (§1.4)
9. **Hub return** — is returning to hub its own status/record? (§4.6)
10. **Summary, rewards, notifications, support, push** — full request/response schemas. (§5–6)
---
*Generated from the current Miler app's live API integration. Every field marked [LIVE] is already sent/consumed by the app in production code — please preserve those names or tell us the new ones so we can migrate.*

1053
MILER_API_REQUIREMENTS.md Normal file

File diff suppressed because it is too large Load Diff

437
MILER_LOGISTICS_API.md Normal file
View File

@@ -0,0 +1,437 @@
# Miler — the logistics line's API contract
**From:** Miler rider-app engineering
**Date:** 25 Aug 2026
**Base URL:** `https://api.doormile.com/api/v1`
**Scope:** the **logistics (parcel) line only** — `ServiceProfile.parcel`.
The meal line (`ServiceProfile.milkMan`) runs the same screens with different
capabilities and calls a strict subset of this; where the two diverge it is
called out.
This is a companion to `MILER_API_REQUIREMENTS.md`, not a replacement. That
document is organised by *request* — what is broken and what we are asking for.
This one is organised by *call order*: what the logistics flow actually sends,
in the sequence it sends it, and which orderings are contractual rather than
incidental. Read this one to implement or verify a handler; read that one for
the open asks.
Everything below is traced from the shipped app — `lib/data/miler_api.dart`,
`lib/providers/pickuplog/pickuplog_provider.dart`,
`lib/controllers/pickups_controller.dart` and the pickup flow under
`lib/views/Dashboard/pickups/`.
---
## 1. What makes the logistics line different
A meal run delivers something that already exists. **The logistics line
creates the shipment at the door.** The rider is the first person who stands in
front of the sender, so he is the first person who can establish the four facts
the consignment will be routed, priced and billed on:
| Fact | Where it comes from | What depends on it |
|---|---|---|
| FROM / TO addresses + pincodes | the rider asks the customer | routing hub, pricing zone |
| Measured weight | the rider's scale | chargeable weight |
| Category + service type | the rider asks | the pricing rule that applies |
| Money taken | the rider's hand | the payment record, the COD ledger |
Everything in §3 follows from that: a booking arrives half-specified and leaves
the door as a consignment with a tracking number.
Capabilities that gate this flow (`lib/data/service_profile.dart`):
```
needsVerification true → the proof-of-work page runs
capturesShipmentAddresses true → the shipment desk runs
initiatesShipment true → the review page raises the order
```
All three are `false` on the meal line, which is why a milk run reaches none of
§3.2 – §3.7.
---
## 2. Cross-cutting contract
These apply to every call below and are not repeated per endpoint.
### 2.1 Auth
`Authorization: Bearer <token>` from `POST /miler/verify-pin`. The **one**
exception is `POST /pricing/check` (§3.3), which is unauthenticated.
The bearer token must never be forwarded to the object store — see §3.8.
### 2.2 Idempotency
Sent as `Idempotency-Key: <verb>:<resource>:<yyyymmdd>` on every write that
moves money or state:
```
payment:<bookingid>:20260825
pickup-complete:<bookingid>:20260825
```
The key is **derived, not random**, so a retry after a dropped acknowledgement
produces the same key and replays the first result. A genuine second attempt
tomorrow gets a new key and is allowed through.
`IDEMPOTENCY_IN_PROGRESS` is not surfaced to the rider. The app waits and
re-asks twice before treating it as a failure — telling a rider his payment
failed while it is in the act of succeeding is the worst answer available.
### 2.3 Error codes
Every 4xx must carry a stable machine-readable `code`. The app branches on
`ApiResult.code` and **never** on message prose. Codes currently consumed:
| Code | Meaning the app acts on |
|---|---|
| `INVALID_STATE` | the resource is not on a rung this call can move it from |
| `IDEMPOTENCY_IN_PROGRESS` | the first attempt is still running — wait, re-ask |
| `EMAIL_IN_USE` | profile edit conflict (409) |
New codes are welcome; new *messages* used as signals are not.
### 2.4 Coordinates
`latitude` / `longitude` on a write always mean **where the rider is standing
at that moment**, not the booking's stored pin. The server computes rider
kilometres by haversine from these and writes them onto the earnings record, so
sending the booking's own coordinates silently zeroes the rider's distance for
that leg.
---
## 3. The flow, in call order
The whole logistics stop, from the rider tapping *I've arrived* to a shipment
existing on the hub's screen:
```
3.1 POST /miler/bookings/:id/reached he is at the door
────── proof of work, on the phone ─────────────────────────────
3.2 (local) parcel count · condition · weight · code · photo
3.3 POST /pricing/check what it costs
3.4 PATCH /miler/bookings/:id/addresses FROM and TO ← must be first
3.5 POST /miler/bookings/:id/parcel measured weight
3.6 POST /miler/bookings/:id/payment the money ← must precede 3.7
3.7 POST /miler/bookings/:id/pickup-complete the shipment exists
────── the consignment is now the subject ──────────────────────
3.8 POST /miler/uploads/sign proof photo ← see §5.1
```
### 3.1 Reached — `POST /miler/bookings/:bookingid/reached`
```jsonc
{ "latitude": 11.0168, "longitude": 76.9558 } // both optional, both sent
```
Records arrival at the pickup address. Guarded on the device against a double
press (`MutationGuard`), so a duplicate is a network replay rather than a
second intent.
> **Open:** this is a deployed no-op on production — see request 15 in
> `MILER_API_REQUIREMENTS.md`. The app still sends it.
### 3.2 The proof of work — no network call
`StopVerificationPage` collects, for a pickup leg: parcel count actually taken,
packaging condition (+ a mandatory note when it is not *Sealed & intact*), total
weight, the pickup code, and one photo of the parcels. Nothing here is posted on
its own. It feeds §3.5 (the weight) and §5.1 (the photo, which on this path is
not uploaded at all).
A count **below** the booked quantity forces a note. That discrepancy is the
single most common cause of a first-mile dispute weeks later, and it is only
knowable at the door.
### 3.3 The price — `POST /pricing/check`
**Unauthenticated.** Called live from the shipment desk as the rider types, so
it must stay cheap.
```jsonc
{
"weight": 2.5, // required, kg, measured not booked
"service_type": "Normal", // "Normal" | "Express"
"pickup_pincode": "641012", // zone is derived server-side from the pair
"delivery_pincode": "641018",
"category": "General" // optional; see the list below
}
```
`category` ∈ `General · Documents · Electronics · Clothing · Fragile · Medical ·
Automotive · Food`.
Response (`data`):
```jsonc
{
"found": true,
"zone": "Local", // Local | Regional | National
"service_type": "Normal",
"weight": 2.5,
"currency": "INR",
"results": [
{ "category": "General", "category_label": "General",
"min_price": 150, "max_price": 180 }
]
}
```
Two contract points that matter:
- **A band, not a number.** The table prices a weight slab in a zone, so it
answers a range. The app quotes `min_price` — that is the figure the customer
was shown when the booking was raised, and quoting the top of a band at a
doorstep is how a rider ends up arguing about money.
- **`found: false` is not zero.** It means no rule covers this
weight/zone/category combination. The app shows "this cannot be priced here"
and refuses to continue. Do not answer `0` for an unpriceable combination —
a zero renders as a free shipment.
The rider does **not** send `zone`. He has just captured two addresses and has
no business deciding what a zone is.
### 3.4 The addresses — `PATCH /miler/bookings/:bookingid/addresses`
**This must land before §3.7.** `pickup-complete` builds the consignment — its
routing hub *and* its pricing zone — from these values, and the handler refuses
an address change once that conversion has happened. There is exactly one window
and this is it.
```jsonc
{
"pickupaddress": "14 Cross Cut Road, Gandhipuram, Coimbatore",
"pickuppincode": "641012",
"pickuplatitude": 11.0168,
"pickuplongitude": 76.9558,
"deliveryaddress": "22 Race Course Road, Coimbatore",
"deliverypincode": "641018",
"deliverylatitude": 11.0043,
"deliverylongitude": 76.9695,
"deliverycity": "Coimbatore"
}
```
Every field is optional and **only non-empty values are applied**. This is a
correction, never a wipe: a booking that arrived with a good pickup address and
a vague destination must keep the good half.
**Failure here stops the flow.** It is the one step in §3 the app refuses to
continue past, because the alternative is a shipment routed and priced from an
address the rider has just been told is wrong, with neither he nor the customer
ever seeing the discrepancy.
### 3.5 The parcels — `POST /miler/bookings/:bookingid/parcel`
```jsonc
{
"parcels": [
{ "weight": 1.25, "length": 0, "width": 0, "height": 0 },
{ "weight": 1.25, "length": 0, "width": 0, "height": 0 }
]
}
```
The rider weighs the **consignment**, not each box, so the total is split evenly
across the collected count. The chargeable total is correct; the per-parcel
figures are a distribution rather than a measurement. Dimensions are sent as
zero — nothing in the flow asks a rider to measure a box, and a made-up number
is worse than an absent one.
`pickup-complete` recomputes chargeable weight from whatever this submitted, so
this is the last moment a measurement can be attached to the shipment at all.
**Failure here does not stop the flow.** The stop completes and bills on the
customer's booked estimate instead of the measured figure. Blocking a rider at
a doorstep over a billing detail is the worse trade.
### 3.6 The money — `POST /miler/bookings/:bookingid/payment`
```jsonc
{ "amount": 150, "paymentmode": "Cash", "transactionref": "" }
```
`paymentmode` ∈ `Cash · UPI · Card · Wallet`. `amount` must be > 0 — the app
skips the call entirely for a prepaid or zero-rated shipment rather than
sending a zero.
**This must precede §3.7.** `pickup-complete` converts the booking into a
consignment, and a payment recorded against a booking that has already been
converted has nothing to attach to. Money first, every time.
The amount is the quote from §3.3, carried through on the stop's
`collectionamt` so the figure the rider showed the customer and the figure the
payment screen asks for cannot drift apart.
> **Miler is the carrier, not the retailer.** This cash belongs to the shipper.
> The app says so on the payment screen and the rider deposits it at the hub —
> the ledger this call writes should reflect custody, not revenue.
### 3.7 The pivot — `POST /miler/bookings/:bookingid/pickup-complete`
```jsonc
{ "latitude": 11.0168, "longitude": 76.9558 }
```
Converts the booking into a consignment, recomputes chargeable weight from
§3.5, mints a tracking number, and decides routing: a shared 3-digit pincode
prefix between pickup and delivery means hyperlocal and the consignment stays in
this rider's hands; otherwise it routes via the hub.
Response must carry the tracking number. The app reads either spelling and shows
it on the success screen:
```jsonc
{ "tracking_no": "DM2608250042" } // "trackingno" also accepted
```
The app calls this through `PickupsController.updatePickedupStatus`, not
directly, so the **geofence** check, the rider-kilometre calculation and the
punctuality bonus all still run. A geofence refusal is not a failure: nothing
was sent, and the rider is told how far off he is.
**Failure here is the one place the app is pessimistic.** Every other status
write in the app is optimistic — a rider who watches a completed stop bounce
back stops trusting the button. Not this one: playing "order created" over a
failed create would send a rider away believing a shipment exists with the
customer's money in his pocket and nothing on the hub's screen.
### 3.8 The proof photo — `POST /miler/uploads/sign` → `PUT <uploadurl>`
Two steps. Step one asks for somewhere to put the image:
```jsonc
{ "purpose": "pickup_proof", "contentType": "image/jpeg", "consignmentid": 4211 }
```
`purpose` ∈ `pickup_proof · delivery_proof · receiver_signature · support`.
```jsonc
{
"uploadurl": "https://…?X-Amz-Signature=…", // expires in 10 minutes
"url": "https://cdn.doormile.com/proofs/…",
"headers": { "x-amz-acl": "private", "Content-Type": "image/jpeg" }
}
```
Step two `PUT`s the bytes to `uploadurl` with **exactly** the headers returned
and nothing else. `x-amz-acl` is part of what was signed, so adding a header of
our own invalidates the signature and the store answers 403. In particular the
bearer token must not be sent to the object store.
A signature that has expired is **re-signed**, not retried against the old URL.
---
## 4. Ordering invariants, stated once
These are the four the app depends on. Three of them are enforced by the
handlers today; they are written down because a future refactor that reorders
them breaks silently rather than loudly.
1. **`addresses` before `pickup-complete`** — routing and zone are built from
them and the handler refuses them afterwards. (§3.4)
2. **`parcel` before `pickup-complete`** — chargeable weight is recomputed from
it, and after conversion there is nothing to attach a measurement to. (§3.5)
3. **`payment` before `pickup-complete`** — a payment cannot attach to a booking
that has become a consignment. (§3.6)
4. **`pickup-complete` before anything consignment-keyed** — the consignment id
does not exist until it returns.
---
## 5. Open, and specific to this line
The general asks live in `MILER_API_REQUIREMENTS.md`. These three shape the
logistics flow in particular.
### 5.1 P0 — pickup proof does not use the signed-upload route
There are **two** upload paths in this app and only one of them is the contract.
| Path | Used by | How |
|---|---|---|
| `MilerApi.uploadProof` → `/miler/uploads/sign` | delivery proof (`delivery_actions.dart`) | server-signed URL, §3.8 |
| `PickupsController.uploadProofImage` | pickup proof: Home's bulk pick, the crate photo | **client-side, straight into the `doormile` Spaces bucket** |
The second one holds a DigitalOcean Spaces access/secret pair in the client. It
is passed by `--dart-define` today rather than being a source literal, but that
only stops the *next* build embedding it: the pair is in this repository's
history and in every APK shipped before the change, it is read-write on the
whole bucket, and it therefore reads and deletes every rider's proof photo for
every tenant. It has to be **rotated**, and the upload has to move behind
`/uploads/sign` like delivery proof already is.
Separately, the single-stop logistics path does not upload at all: the
verification page takes a photo of the parcels and `updatePickedupStatus` sends
`proofImage: ''`. A disputed first-mile collection has the rider's word and a
count, and no picture.
**Asks:**
1. Rotate the Spaces credential pair. This is not conditional on anything below.
2. Confirm `/uploads/sign` accepts `purpose: "pickup_proof"` keyed on a
**`bookingid`** — the photo is taken before §3.7, so no consignment id
exists yet. `consignmentid` is the only resource key the sign call documents
today.
3. With (2) answered, the app moves both pickup paths onto §3.8 and
`uploadProofImage` is deleted.
### 5.2 P0 — `step` is `0` on every booking row
`GET /miler/bookings` returns `step`, `stoptype`, `etaminutes`, `cumulativekms`
and `cumulativeeta`, and the app consumes all five. `step` arrives as `0` on
every live row, so the admin's route order cannot be followed and the app falls
back to sorting nearest-first. This is request 13 in the main document and it is
the largest single gap on this line: a rider works a hub-planned route by
guessing at it.
### 5.3 P2 — `vehicle-required` has no way in
`POST /miler/bookings/:id/vehicle-required` exists and is wired
(`UpdatePickupProvider.requireVehicle`) but is unreachable — there is no control
anywhere in the app for "this doesn't fit on a bike". That is a design question
about where a rider says it, not a backend gap. Listed so the endpoint is not
assumed dead and removed.
---
## 6. Field-name appendix
The wire uses lowercase, unseparated names. The app's own maps use camelCase and
translate at the adapter boundary; these are the names on the wire.
| Wire | Type | Where |
|---|---|---|
| `bookingid` | int | path param, §3.1 – §3.7 |
| `consignmentid` | int | after §3.7; `/uploads/sign`, `/consignments/*` |
| `pickupaddress` `pickuppincode` `pickuplatitude` `pickuplongitude` | string / string / num / num | §3.4 |
| `deliveryaddress` `deliverypincode` `deliverylatitude` `deliverylongitude` `deliverycity` | string / string / num / num / string | §3.4 |
| `parcels[].weight` `.length` `.width` `.height` | num | §3.5 |
| `amount` `paymentmode` `transactionref` | num / enum / string | §3.6 |
| `latitude` `longitude` | num | §3.1, §3.7 — **rider's position** |
| `tracking_no` \| `trackingno` | string | §3.7 response |
| `weight` `service_type` `pickup_pincode` `delivery_pincode` `category` | num / enum / string / string / enum | §3.3 request |
| `found` `zone` `currency` `results[].min_price` `.max_price` | bool / enum / string / num / num | §3.3 response |
| `purpose` `contentType` | enum / string | §3.8 request |
| `uploadurl` `url` `headers` | string / string / object | §3.8 response |
| `step` `stoptype` `etaminutes` `cumulativekms` `cumulativeeta` | int / enum / int / num / int | `GET /miler/bookings` row |
---
## 7. What this line does *not* call
Recorded so a handler is not written for a caller that does not exist:
- `POST /miler/deliveries/start` — never existed, never call it. `pickup-complete`
decides routing itself.
- `POST /miler/consignments/:id/start-delivery` — the **release**, and only
reachable when a logistics consignment stays in the rider's hands
(hyperlocal). A shipment routed via the hub leaves his custody at §3.7 and he
never delivers it.
- Everything under the meal line's collect-a-crate path. A milk run reaches
§3.1 and then its own confirmation sheet; §3.2 – §3.8 do not run.

View File

@@ -0,0 +1,158 @@
# P0 — The hub's route order never reaches the rider
**From:** Miler rider-app engineering · 24 Aug 2026
**To:** Doormile backend
**Endpoints:** `GET /miler/bookings`, `GET /miler/assignments`
**Related:** *API requirements* §11b (request 13), open questions 6 and 7
---
## The rule we are building to
> **The console decides the order. The rider follows it.**
If operations has solved a route, the app must work the stops in that exact
order — both legs, pickup and delivery — and must never re-sort by what is
closest to the rider. Re-optimising a planned route is not a small difference:
it changes the arrival windows customers were promised, and it makes the hub's
own screen a fiction.
This is already how the app behaves. `lib/data/route_order.dart` is the single
place that answers it, and its rule is:
| The payload says | The app does | The rider is told |
|---|---|---|
| any stop carries a sequence | works them in that order, exactly | **Hub route** |
| no sequence, but booked times | orders by booked time | *No route assigned — ordered by booked time* |
| no sequence, no times | keeps the backend's own array order | *No route assigned — shown in the order they came through* |
| nothing at all, rider has GPS | nearest-first | *No route assigned — ordered by what is closest to you* |
Nearest-first is the **last** of four, it is labelled as a guess wherever it
appears, and one sequenced stop is enough to switch the whole set into hub
order.
---
## What is happening instead
**Every live row comes back `step: 0`.** Verified in production, rider Rajan A
(`userid 38`), 21–24 Aug:
```
GET /miler/bookings → 29 rows · step = 0 on all 29
GET /miler/assignments → 12 rows · step = 0 on all 12, sequencedat = null
```
`step` is on the contract and is present in the payload. It is simply never
written. So the first branch of the table above has never once been taken on a
real device, and every rider in production is working a route the app ordered,
while the console believes he is working the route it planned.
Neither side is currently able to notice. That is the part we would most like
to close.
### Why it bites hardest on the delivery leg
`GET /miler/assignments` is — correctly, and you have confirmed this — the
**active** queue: `Assigned` / `Accepted` only. A booking leaves that queue the
moment it is collected.
The delivery leg begins the moment it is collected.
So even if `step` were populated on assignments tomorrow, the sequence would
vanish at exactly the point the rider starts delivering, and the delivery
half of his day would still be ordered by the app. **The sequence has to be on
`GET /miler/bookings`**, which is the only list that covers all of a rider's
work.
---
## What we need
### 1. Populate `step` on every routed stop
On **both** `GET /miler/bookings` and `GET /miler/assignments`:
```jsonc
{
"bookingid": 59,
"stoptype": "delivery",
"step": 4, // 1-based position in the solved route
"sequencedat": "2026-08-24 07:12:00", // when the route was solved, IST
"tripid": 2 // see §3
}
```
- **`step` is 1-based.** `0` keeps its current meaning — *this stop is not in a
solved route* — and the app already sends those to the end of the list.
- **Stable for the day.** A stop's step must not change between two polls
unless the route was genuinely re-solved. The app re-sorts on every fetch;
a step that drifts makes the list reshuffle under the rider's thumb.
- **Unique within a rider's route**, so two stops cannot claim position 4.
- **Survives the leg change.** The step a booking had as a pickup, or the step
its delivery has, must still be on the row after `pickup-complete` — that is
the case that is broken today.
### 2. `sequencedat`, preserved
Null means *never solved*. A timestamp means *solved then*. The app uses it for
one thing only: telling a rider **"no route assigned"** honestly instead of
implying the hub planned an order it did not. It is currently null on every row
even where a step exists.
### 3. Confirm who owns Trip 1 / Trip 2 / Trip 3
A rider's day is split into up to three runs. Today the app derives that split
**client-side, from the day part** — morning / afternoon / evening — because
nothing in the payload names a run.
That is a guess, and it is the wrong kind: a rider's "Trip 2" and the hub's
"Trip 2" are not necessarily the same set of stops, and neither screen can tell.
Two workable answers, and we need to know which:
- **The console owns runs** → return `tripid` (and `slotid` if they differ) on
every row of `GET /miler/bookings`, stable for the day. We follow it and
delete the day-part split entirely.
- **Nobody owns runs** → say so, and the day-part split stays as documented
client behaviour rather than an unlabelled invention.
---
## Two questions we cannot answer from this side
1. **Is sequencing automatic or manual?** Is a route solved when assignments
are created, or does an operator have to trigger a route/optimizer action?
If it is manual, then `step: 0` is often the *correct* answer, and the app
should say **"no route assigned"** plainly rather than quietly ordering the
stops itself — which is a different piece of work from populating the field.
2. **What happens on a mid-day re-solve?** If operations re-sequences a route
while a rider is part-way through it, do completed stops keep their old
steps? We will follow whatever you send; we need to know whether to expect
the numbers to move.
---
## How to tell it is fixed
No app release is needed for any of this — the app reads these fields today.
1. `GET /miler/bookings` for a rider with a solved route returns `step ≥ 1` on
the routed stops and a non-null `sequencedat`.
2. The same booking still carries its `step` **after** `pickup-complete`.
3. Two consecutive polls return the same steps.
4. On the rider's phone, the queue heading reads **Hub route** rather than
*Nearest first* — that string is driven directly by which branch of the
table above was taken, so it is a one-glance check that the sequence
actually arrived.
---
## What we are not asking for
- Not a routing engine. If the console already stores an order, exposing it is
enough; we are not asking anyone to solve TSP.
- Not a new endpoint. Two fields on two existing list responses.
- Not a change to `/miler/assignments`' scoping. Active-queue-only is right —
it is precisely why `/miler/bookings` has to carry the sequence.

View File

@@ -1,16 +0,0 @@
# Doormile
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -11,6 +11,14 @@ analyzer:
errors: errors:
file_names: ignore file_names: ignore
unused_field: ignore unused_field: ignore
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:

View File

@@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) {
// the map rendered as a blank grey grid. // the map rendered as a blank grey grid.
android { android {
namespace = "com.doormile.partner" namespace = "com.doormile.miler"
compileSdk = 36 compileSdk = 36
// Correct NDK version // Correct NDK version
@@ -52,7 +52,7 @@ android {
} }
defaultConfig { defaultConfig {
applicationId = "com.doormile.partner" applicationId = "com.doormile.miler"
minSdkVersion flutter.minSdkVersion minSdkVersion flutter.minSdkVersion
targetSdkVersion 36 targetSdkVersion 36

View File

@@ -126,7 +126,7 @@
"client_info": { "client_info": {
"mobilesdk_app_id": "1:140444764229:android:578383f5a1d3a05c283b2c", "mobilesdk_app_id": "1:140444764229:android:578383f5a1d3a05c283b2c",
"android_client_info": { "android_client_info": {
"package_name": "com.doormile.partner" "package_name": "com.doormile.miler"
} }
}, },
"oauth_client": [ "oauth_client": [

View File

@@ -3,10 +3,37 @@
<!-- ===== Permissions ===== --> <!-- ===== Permissions ===== -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- READ_MEDIA_IMAGES was declared here and REMOVED 2026-09-19, because
<!-- For older Android versions --> Play's photo and video permissions policy refused it and was right to.
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> The policy allows it only where a system picker is "technically
<uses-permission android:name="android.permission.PICTURE_IN_PICTURE" /> insufficient to provide core app functionality", and here it is not
even close:
* every operational photograph opens the CAMERA directly — parcel
shots at pickup, proof of delivery at the door, stop verification.
None of them read the library, deliberately: a picture chosen from
the gallery could have been taken anywhere at any time and would
not be evidence that a parcel was collected or delivered;
* the ONE gallery read in the app is a rider setting their profile
photograph, once, at sign-up.
So the app's real need is the textbook "one-time or infrequent" case
the policy points at the Android photo picker for. image_picker 1.1.2
already routes `ImageSource.gallery` through that system picker on
Android 13+ when this permission is absent, so removing it keeps the
profile picker working and needs no permission at all. -->
<!-- For older Android versions, where the photo picker does not exist.
Capped at 32: from 33 the system picker above governs this, and
requesting broad storage on a modern device is both ineffective and a
Play review flag. -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- android.permission.PICTURE_IN_PICTURE was declared here and does not
exist. PiP is enabled by android:supportsPictureInPicture on the
activity, which is set below; the OS ignores the bogus name, but Play's
listing shows every declared permission and an unrecognised one invites
a reviewer's question we have no answer to. -->
<!-- Android 13+ notifications --> <!-- Android 13+ notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
@@ -19,23 +46,63 @@
<!-- Location permissions --> <!-- Location permissions -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Optional: needed for background tracking (break auto-refresh, etc.) --> <!-- ACCESS_BACKGROUND_LOCATION was declared here and REMOVED 2026-09-16.
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> The app never asked for it: `LocationPermission.always` and
<!-- Phone call (for tap-to-call customer / pickup) --> `Permission.locationAlways` appear nowhere in lib/, and every call site
<uses-permission android:name="android.permission.CALL_PHONE" /> is a plain `Geolocator.requestPermission()`, which requests
while-in-use. So the declaration bought nothing and cost a great deal —
Play treats background location as a high-risk permission requiring a
declaration form, a prominent in-app disclosure, a privacy-policy
clause and a video walkthrough of the feature that justifies it. There
is no such feature.
On-duty tracking keeps working: it runs inside the foreground service
declared below, which is the supported pattern and needs only
FOREGROUND_SERVICE_LOCATION plus the while-in-use grant. -->
<!-- CALL_PHONE was declared here and REMOVED 2026-09-16. Every "call the
customer" control in the app builds `Uri(scheme: 'tel', path: …)` and
hands it to `launchUrl`, which is ACTION_VIEW — it *opens the dialer*
with the number filled in and the rider presses the green button.
CALL_PHONE is only needed to place a call directly with ACTION_CALL,
which this app never does. Declaring it asks the rider to grant the
app the ability to dial without him, for a feature that does not
exist. -->
<!-- Alarm permissions for shift end (works even when app is killed) --> <!-- Alarm permissions for shift end (works even when app is killed) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- SCHEDULE_EXACT_ALARM stays: it is user-grantable, and both alarm call
sites already check `alarmManager.canScheduleExactAlarms()` and fall
back to `setAndAllowWhileIdle` when it is refused (MainActivity.kt:256,
ShiftEndReceiver.kt:140).
USE_EXACT_ALARM was declared beside it and is REMOVED 2026-09-16. It is
a *restricted* permission: Play grants it only to apps whose core
function is an alarm clock, calendar or timer. A shift-end reminder is
none of those, so it is a policy rejection waiting to happen — and
because of the fallback above, removing it costs at most a few minutes
of drift on the end-of-shift notification when the rider has not
granted exact alarms. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- ===== Application ===== --> <!-- ===== Application ===== -->
<!-- usesCleartextTraffic was `true`, commented "allows http if any". There
is no http:// endpoint left in the app: every host it talks to —
api.doormile.com, the OSM tile server and the OSRM
router — is https, and all of them present valid publicly trusted
certificates.
Left on, the flag is a standing invitation: a URL that later arrives
from a payload or a config would be fetched in the clear on the
rider's mobile network, carrying his session and his position. Off,
that request fails and somebody fixes the URL. Turned off alongside
the certificate bypass in MyHttpOverrides, which was the other half
of the same hole. -->
<application <application
android:label="Doormile Rider" android:label="Miler"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> <!-- allows http if any --> android:usesCleartextTraffic="false">
<!-- No maps key. The Google Maps SDK is no longer used: tiles come <!-- No maps key. The Google Maps SDK is no longer used: tiles come
from OpenStreetMap and routes from OSRM, both keyless, drawn by from OpenStreetMap and routes from OSRM, both keyless, drawn by
@@ -96,7 +163,7 @@
android:enabled="true" android:enabled="true"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="com.doormile.partner.SHIFT_END_ALARM" /> <action android:name="com.doormile.miler.SHIFT_END_ALARM" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@@ -123,10 +190,6 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<data android:scheme="tel" /> <data android:scheme="tel" />
</intent> </intent>
<intent>
<action android:name="android.intent.action.CALL" />
<data android:scheme="tel" />
</intent>
<!-- Allow launching turn-by-turn navigation / maps for "Start pickup" <!-- Allow launching turn-by-turn navigation / maps for "Start pickup"
(Android 11+ requires declaring these to resolve the intents). --> (Android 11+ requires declaring these to resolve the intents). -->

View File

@@ -1,4 +1,4 @@
package com.doormile.partner package com.doormile.miler
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
@@ -188,7 +188,7 @@ class MainActivity : FlutterActivity() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, ShiftEndReceiver::class.java).apply { val intent = Intent(this, ShiftEndReceiver::class.java).apply {
action = "com.doormile.partner.SHIFT_END_ALARM" action = "com.doormile.miler.SHIFT_END_ALARM"
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
this, this,
@@ -241,7 +241,7 @@ class MainActivity : FlutterActivity() {
// Trigger the receiver immediately // Trigger the receiver immediately
val immediateIntent = Intent(this, ShiftEndReceiver::class.java).apply { val immediateIntent = Intent(this, ShiftEndReceiver::class.java).apply {
action = "com.doormile.partner.SHIFT_END_ALARM" action = "com.doormile.miler.SHIFT_END_ALARM"
} }
sendBroadcast(immediateIntent) sendBroadcast(immediateIntent)
Log.d("MainActivity", "✅ Sent immediate broadcast to ShiftEndReceiver") Log.d("MainActivity", "✅ Sent immediate broadcast to ShiftEndReceiver")
@@ -297,7 +297,7 @@ class MainActivity : FlutterActivity() {
try { try {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, ShiftEndReceiver::class.java).apply { val intent = Intent(this, ShiftEndReceiver::class.java).apply {
action = "com.doormile.partner.SHIFT_END_ALARM" action = "com.doormile.miler.SHIFT_END_ALARM"
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
this, this,

View File

@@ -1,278 +1,232 @@
package com.doormile.partner package com.doormile.miler
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/** /**
* BroadcastReceiver that triggers when shift end alarm fires * BroadcastReceiver that triggers when shift end alarm fires
* This works even when app is killed * This works even when app is killed
* *
* When app is killed, we directly call the API to create break log * When app is killed, we directly call the API to create break log
* since we can't reliably start Flutter engine * since we can't reliably start Flutter engine
*/ */
class ShiftEndReceiver : BroadcastReceiver() { class ShiftEndReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d("ShiftEndReceiver", "🔔 Shift end alarm triggered at ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Calendar.getInstance().time)}") Log.d("ShiftEndReceiver", "🔔 Shift end alarm triggered at ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Calendar.getInstance().time)}")
// ✅ CRITICAL: Acquire WakeLock to keep device awake during API call // ✅ CRITICAL: Acquire WakeLock to keep device awake during API call
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock") val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock")
wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max
// Execute API call in background thread // Execute API call in background thread
Thread { Thread {
try { try {
// Check if rider is still on duty // Check if rider is still on duty
val prefs: SharedPreferences = context.getSharedPreferences( val prefs: SharedPreferences = context.getSharedPreferences(
"FlutterSharedPreferences", "FlutterSharedPreferences",
Context.MODE_PRIVATE Context.MODE_PRIVATE
) )
// ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix) // ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix)
val onduty = prefs.getInt("flutter.onduty", 0) val onduty = prefs.getInt("flutter.onduty", 0)
Log.d("ShiftEndReceiver", "Checking onduty: $onduty") Log.d("ShiftEndReceiver", "Checking onduty: $onduty")
if (onduty != 1) { if (onduty != 1) {
Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation") Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation")
return@Thread return@Thread
} }
// Get required IDs - try both flutter.userid and flutter.userId // Get required IDs - try both flutter.userid and flutter.userId
val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 } val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 }
?: prefs.getInt("flutter.userId", 0) ?: prefs.getInt("flutter.userId", 0)
val partnerid = prefs.getInt("flutter.partnerid", 0).takeIf { it != 0 }
?: prefs.getInt("flutter.partnerId", 0) if (userid == 0) {
val shiftid = prefs.getInt("flutter.shiftid", 0).takeIf { it != 0 } Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}")
?: prefs.getInt("flutter.shiftId", 0) return@Thread
val logid = prefs.getInt("flutter.logid", 0).takeIf { it != 0 } }
?: prefs.getInt("flutter.logId", 0)
// ── v1, with the session, and no ids in the body ──
Log.d("ShiftEndReceiver", "Retrieved IDs: userid=$userid, partnerid=$partnerid, shiftid=$shiftid, logid=$logid") //
// This block hand-posted two legacy routes over HttpURLConnection:
if (userid == 0) { // partners/createbreaklog and partners/updateriderlog, on
Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}") // the retired pre-v1 backend,
return@Thread // with `userid`, `partnerid`, `shiftid` and `logid` in the body and
} // NO Authorization header — an unauthenticated write keyed on ids
// read out of SharedPreferences.
// Get API base URL (check if live or dev) //
// ✅ Match homepage logic: use createbreaklog (not createbreakriderlog) // The v1 equivalents key on the bearer token instead: the server
val mainRoute = prefs.getString("flutter.mainRoute", "dev") ?: "dev" // knows which rider the session names, so the ids are neither sent
val isLive = mainRoute == "live" // nor needed. Same two effects, one host, one auth model as the
Log.d("ShiftEndReceiver", "API Route: $mainRoute (isLive=$isLive)") // rest of the app.
val baseUrl = if (isLive) { //
"https://jupiter.doormile.app/live/api/v2/partners/createbreaklog" // No token means no shift to end. That is the correct outcome —
} else { // a signed-out handset must not be able to close somebody's duty.
"https://jupiter.doormile.app/dev/api/v2/partners/createbreaklog" val token = prefs.getString("flutter.authtoken", "") ?: ""
} if (token.isEmpty()) {
Log.e("ShiftEndReceiver", "No session token; cannot end shift")
// Create break log payload return@Thread
val now = Calendar.getInstance() }
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) val breakOk = callV1(
val breakdate = dateFormat.format(now.time) path = "/miler/breaks/start",
val breakstart = timeFormat.format(now.time) method = "POST",
val localBreakId = (System.currentTimeMillis() % 900).toInt() + 100 token = token,
body = """{"breaktype":"Shift_End"}"""
// ✅ Compact JSON (no extra whitespace) - matches Flutter format )
val payload = """{"breakid":$localBreakId,"logid":$logid,"breakdate":"$breakdate","userid":$userid,"partnerid":$partnerid,"shiftid":$shiftid,"breakstart":"$breakstart","breakend":"","breakhours":0.0,"latitude":"0","longitude":"0"}"""
if (breakOk) {
Log.d("ShiftEndReceiver", "📤 Sending break log request to: $baseUrl") Log.d("ShiftEndReceiver", "Break log created successfully")
Log.d("ShiftEndReceiver", "📦 Payload: $payload")
prefs.edit()
// Make API call .putInt("flutter.onduty", 0)
val url = URL(baseUrl) .putBoolean("flutter.online", false)
val connection = url.openConnection() as HttpURLConnection .apply()
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json") // `PUT /miler/duty/end` takes no body and closes whichever duty
connection.setRequestProperty("Accept", "application/json") // log is open for the token's rider.
connection.doOutput = true if (callV1("/miler/duty/end", "PUT", token, null)) {
connection.doInput = true Log.d("ShiftEndReceiver", "Rider status updated to Offline")
connection.useCaches = false }
connection.connectTimeout = 15000 // Increased timeout } else {
connection.readTimeout = 15000 Log.e("ShiftEndReceiver", "Failed to create break log")
}
Log.d("ShiftEndReceiver", "🔌 Connecting to API...")
// ✅ CRITICAL: Reschedule alarm for tomorrow (so it works every day automatically)
// Write payload // This ensures the alarm fires every day at shift end time even if app is killed
val outputStream = connection.outputStream try {
val writer = OutputStreamWriter(outputStream, "UTF-8") val endTimeStr = prefs.getString("flutter.endtime", "") ?: ""
writer.write(payload) val startTimeStr = prefs.getString("flutter.starttime", "") ?: ""
writer.flush() Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr")
writer.close()
if (endTimeStr.isNotEmpty()) {
Log.d("ShiftEndReceiver", "📨 Request sent, waiting for response...") // Schedule alarm for tomorrow at the same time
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val responseCode = connection.responseCode val intent = Intent(context, ShiftEndReceiver::class.java).apply {
Log.d("ShiftEndReceiver", "📥 Break log API response: HTTP $responseCode") action = "com.doormile.miler.SHIFT_END_ALARM"
}
// Read response body for debugging val pendingIntent = PendingIntent.getBroadcast(
try { context,
val responseStream = if (responseCode in 200..299) { 1001,
connection.inputStream intent,
} else { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
connection.errorStream )
}
if (responseStream != null) { val timeParts = endTimeStr.split(":")
val responseBody = responseStream.bufferedReader().use { it.readText() } if (timeParts.size >= 2) {
Log.d("ShiftEndReceiver", "📄 Response body: $responseBody") val endHour = timeParts[0].toIntOrNull() ?: 0
} val endMinute = timeParts[1].toIntOrNull() ?: 0
} catch (e: Exception) {
Log.w("ShiftEndReceiver", "Could not read response body: ${e.message}") val calendar = Calendar.getInstance().apply {
} add(Calendar.DAY_OF_MONTH, 1) // Tomorrow
set(Calendar.HOUR_OF_DAY, endHour)
if (responseCode in 200..299) { set(Calendar.MINUTE, endMinute)
Log.d("ShiftEndReceiver", "✅ Break log created successfully (HTTP $responseCode)") set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
// Set offline locally }
prefs.edit()
.putInt("flutter.onduty", 0) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
.putBoolean("flutter.online", false) if (alarmManager.canScheduleExactAlarms()) {
.apply() alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
// Also update rider log to set onduty=0 calendar.timeInMillis,
val updateUrl = if (isLive) { pendingIntent
"https://jupiter.doormile.app/live/api/v2/partners/updateriderlog" )
} else { Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}")
"https://jupiter.doormile.app/dev/api/v2/partners/updateriderlog" } else {
} alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
// ✅ Compact JSON (no extra whitespace) calendar.timeInMillis,
val updatePayload = """{"userid":$userid,"onduty":0,"latitude":"0","longitude":"0"}""" pendingIntent
)
Log.d("ShiftEndReceiver", "📤 Updating rider status to offline") }
} else {
try { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
val updateConnection = URL(updateUrl).openConnection() as HttpURLConnection alarmManager.setExactAndAllowWhileIdle(
updateConnection.requestMethod = "POST" AlarmManager.RTC_WAKEUP,
updateConnection.setRequestProperty("Content-Type", "application/json") calendar.timeInMillis,
updateConnection.doOutput = true pendingIntent
updateConnection.connectTimeout = 5000 )
updateConnection.readTimeout = 5000 } else {
@Suppress("DEPRECATION")
val updateWriter = OutputStreamWriter(updateConnection.outputStream, "UTF-8") alarmManager.setExact(
updateWriter.write(updatePayload) AlarmManager.RTC_WAKEUP,
updateWriter.flush() calendar.timeInMillis,
updateWriter.close() pendingIntent
)
if (updateConnection.responseCode in 200..299) { }
Log.d("ShiftEndReceiver", "✅ Rider status updated to Offline") Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}")
} }
updateConnection.disconnect() }
} catch (e: Exception) { }
Log.e("ShiftEndReceiver", "Error updating rider status: ${e.message}") } catch (e: Exception) {
} Log.e("ShiftEndReceiver", "Error rescheduling alarm: ${e.message}", e)
} else { }
// Read error response for debugging } catch (e: Exception) {
try { Log.e("ShiftEndReceiver", "❌ Error handling shift end: ${e.message}", e)
val errorStream = connection.errorStream e.printStackTrace()
if (errorStream != null) { } finally {
val errorResponse = errorStream.bufferedReader().use { it.readText() } // ✅ CRITICAL: Release WakeLock in finally block
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode\nError: $errorResponse") try {
} else { if (wakeLock.isHeld) {
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode") wakeLock.release()
} Log.d("ShiftEndReceiver", "🔓 WakeLock released")
} catch (e: Exception) { }
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode (Error reading response: ${e.message})") } catch (e: Exception) {
} Log.e("ShiftEndReceiver", "Error releasing WakeLock: ${e.message}")
} }
}
connection.disconnect() }.start()
}
// ✅ CRITICAL: Reschedule alarm for tomorrow (so it works every day automatically)
// This ensures the alarm fires every day at shift end time even if app is killed /**
try { * One authenticated call to the v1 backend. Returns true on 2xx.
val endTimeStr = prefs.getString("flutter.endtime", "") ?: "" *
val startTimeStr = prefs.getString("flutter.starttime", "") ?: "" * The single place this file talks to a network, so there is one base URL,
Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr") * one auth model and one timeout policy rather than a copy per endpoint.
*/
if (endTimeStr.isNotEmpty()) { private fun callV1(path: String, method: String, token: String, body: String?): Boolean {
// Schedule alarm for tomorrow at the same time var connection: HttpURLConnection? = null
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager return try {
val intent = Intent(context, ShiftEndReceiver::class.java).apply { connection = URL(V1_BASE + path).openConnection() as HttpURLConnection
action = "com.doormile.partner.SHIFT_END_ALARM" connection.requestMethod = method
} connection.setRequestProperty("Content-Type", "application/json")
val pendingIntent = PendingIntent.getBroadcast( connection.setRequestProperty("Accept", "application/json")
context, connection.setRequestProperty("Authorization", "Bearer $token")
1001, connection.connectTimeout = 15000
intent, connection.readTimeout = 15000
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE if (body != null) {
) connection.doOutput = true
OutputStreamWriter(connection.outputStream, "UTF-8").use {
val timeParts = endTimeStr.split(":") it.write(body)
if (timeParts.size >= 2) { it.flush()
val endHour = timeParts[0].toIntOrNull() ?: 0 }
val endMinute = timeParts[1].toIntOrNull() ?: 0 }
val code = connection.responseCode
val calendar = Calendar.getInstance().apply { Log.d("ShiftEndReceiver", "$method $path -> HTTP $code")
add(Calendar.DAY_OF_MONTH, 1) // Tomorrow code in 200..299
set(Calendar.HOUR_OF_DAY, endHour) } catch (e: Exception) {
set(Calendar.MINUTE, endMinute) Log.e("ShiftEndReceiver", "$method $path failed: ${e.message}")
set(Calendar.SECOND, 0) false
set(Calendar.MILLISECOND, 0) } finally {
} connection?.disconnect()
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { }
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle( companion object {
AlarmManager.RTC_WAKEUP, /** The one backend this app talks to. */
calendar.timeInMillis, private const val V1_BASE = "https://api.doormile.com/api/v1"
pendingIntent }
)
Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}") }
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
}
} else {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
} else {
@Suppress("DEPRECATION")
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
}
Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}")
}
}
}
} catch (e: Exception) {
Log.e("ShiftEndReceiver", "Error rescheduling alarm: ${e.message}", e)
}
} catch (e: Exception) {
Log.e("ShiftEndReceiver", "❌ Error handling shift end: ${e.message}", e)
e.printStackTrace()
} finally {
// ✅ CRITICAL: Release WakeLock in finally block
try {
if (wakeLock.isHeld) {
wakeLock.release()
Log.d("ShiftEndReceiver", "🔓 WakeLock released")
}
} catch (e: Exception) {
Log.e("ShiftEndReceiver", "Error releasing WakeLock: ${e.message}")
}
}
}.start()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="0%" />
</foreground>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -19,6 +19,59 @@ subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }
// ── Compiling maplibre_gl against the JDK we actually have ──
//
// `maplibre_gl` is the map engine behind the delivery line's screens (the
// ported Xpress-rider flow under lib/xpress). Version 0.26.2 declares
// `sourceCompatibility = 21`, and javac refuses a source release newer than the
// JDK running the build:
//
// Execution failed for task ':maplibre_gl:compileDebugJavaWithJavac'
// > error: invalid source release: 21
//
// Flutter here is configured against JDK 17, so every build fails at that task
// — including builds of the parcel line, which never loads a MapLibre map.
//
// The plugin's own sources are Java 8-compatible; 21 is the level it asks for,
// not one it needs. So the level is lowered to 17 for this one subproject.
//
// Deliberately scoped by name rather than applied to every subproject: a blanket
// override would silently change the bytecode target of ~20 unrelated plugins
// that build fine today, to fix one that does not.
//
// The real fix is a JDK 21 toolchain (`flutter config --jdk-dir=...`). When this
// machine and CI both have one, delete this block and let the plugin have the
// level it asked for.
// Both halves must move together: Kotlin and Java have to agree on a JVM target
// or the Kotlin plugin fails the build itself ("Inconsistent JVM-target
// compatibility"). Setting only the JavaCompile tasks is not enough either —
// AGP writes `compileOptions` from the library extension after this file is
// evaluated, so the override has to happen in `afterEvaluate` to land last.
subprojects {
if (name == "maplibre_gl") {
afterEvaluate {
extensions.findByName("android")?.let { ext ->
ext.withGroovyBuilder {
"compileOptions" {
setProperty("sourceCompatibility", JavaVersion.VERSION_17)
setProperty("targetCompatibility", JavaVersion.VERSION_17)
}
}
}
tasks.withType<JavaCompile>().configureEach {
sourceCompatibility = JavaVersion.VERSION_17.toString()
targetCompatibility = JavaVersion.VERSION_17.toString()
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>()
.configureEach {
compilerOptions.jvmTarget.set(
org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17,
)
}
}
}
}
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)
} }

File diff suppressed because one or more lines are too long

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -1,4 +0,0 @@
storePassword=123456789
keyPassword=123456789
keyAlias= doormile
storeFile=doormilerider-keystore.jks

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.google.gms.google-services") version "4.4.2" apply false id("com.google.gms.google-services") version "4.4.2" apply false
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
assets/images/caught_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/images/homeicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
assets/images/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/images/onboard_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/images/onboard_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
assets/images/summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

BIN
assets/images/today.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/images/total.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -4,15 +4,6 @@ PODS:
- Flutter - Flutter
- flutter_tts (0.0.1): - flutter_tts (0.0.1):
- Flutter - Flutter
- Google-Maps-iOS-Utils (6.1.0):
- GoogleMaps (~> 9.0)
- google_maps_flutter_ios (0.0.1):
- Flutter
- Google-Maps-iOS-Utils (< 7.0, >= 5.0)
- GoogleMaps (< 10.0, >= 8.4)
- GoogleMaps (9.4.0):
- GoogleMaps/Maps (= 9.4.0)
- GoogleMaps/Maps (9.4.0)
- permission_handler_apple (9.3.0): - permission_handler_apple (9.3.0):
- Flutter - Flutter
- sms_autofill (0.0.1): - sms_autofill (0.0.1):
@@ -24,16 +15,10 @@ DEPENDENCIES:
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
- google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- sms_autofill (from `.symlinks/plugins/sms_autofill/ios`) - sms_autofill (from `.symlinks/plugins/sms_autofill/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`) - vibration (from `.symlinks/plugins/vibration/ios`)
SPEC REPOS:
trunk:
- Google-Maps-iOS-Utils
- GoogleMaps
EXTERNAL SOURCES: EXTERNAL SOURCES:
Flutter: Flutter:
:path: Flutter :path: Flutter
@@ -41,8 +26,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_foreground_task/ios" :path: ".symlinks/plugins/flutter_foreground_task/ios"
flutter_tts: flutter_tts:
:path: ".symlinks/plugins/flutter_tts/ios" :path: ".symlinks/plugins/flutter_tts/ios"
google_maps_flutter_ios:
:path: ".symlinks/plugins/google_maps_flutter_ios/ios"
permission_handler_apple: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
sms_autofill: sms_autofill:
@@ -51,12 +34,9 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/vibration/ios" :path: ".symlinks/plugins/vibration/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
sms_autofill: b36b2147535657fea83d7f3898d7831de70bd8e4 sms_autofill: b36b2147535657fea83d7f3898d7831de70bd8e4
vibration: 69774ad57825b11c951ee4c46155f455d7a592ce vibration: 69774ad57825b11c951ee4c46155f455d7a592ce

View File

@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git", "location" : "https://github.com/google/app-check.git",
"state" : { "state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.0" "version" : "11.3.1"
} }
}, },
{ {
@@ -23,8 +23,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", "revision" : "346daa9f46316aa372b35b317e18224acc2e9063",
"version" : "12.14.0" "version" : "12.18.0"
} }
}, },
{ {
@@ -41,8 +41,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.6.0" "version" : "3.6.1"
} }
}, },
{ {
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5",
"version" : "12.14.0" "version" : "12.18.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git", "location" : "https://github.com/google/GoogleDataTransport.git",
"state" : { "state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.0" "version" : "10.1.1"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.1" "version" : "8.1.3"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git", "location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : { "state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
"version" : "5.3.0" "version" : "5.3.1"
} }
}, },
{ {
@@ -108,6 +108,15 @@
"version" : "1.22.5" "version" : "1.22.5"
} }
}, },
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
"version" : "6.27.0"
}
},
{ {
"identity" : "nanopb", "identity" : "nanopb",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -108,6 +108,15 @@
"version" : "1.22.5" "version" : "1.22.5"
} }
}, },
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
"version" : "6.27.0"
}
},
{ {
"identity" : "nanopb", "identity" : "nanopb",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -1,3 +1,9 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// Canonical lifecycle status for a booking/stop, parsed from the backend's /// Canonical lifecycle status for a booking/stop, parsed from the backend's
/// free-form `orderstatus` string. /// free-form `orderstatus` string.
/// ///
@@ -16,6 +22,32 @@ enum StopStatus {
active, active,
arrived, arrived,
picked, picked,
/// ── The milk run's second half ──
///
/// [picked] is where a logistics stop ends: the parcel is collected and it
/// becomes the hub's problem. A milk run does not end there — collection is
/// the middle of the day, and the rider still has to carry the load to the
/// customers.
///
/// So these three rungs exist for the line that has them, and only that line
/// puts a stop into them. Keeping them in the same enum is what lets one
/// `stopStatusOf` test serve both halves of the day; keeping them *distinct
/// from* [picked] and [arrived] is what stops a collected crate being
/// reported as fifteen delivered lunches ninety minutes early.
///
/// The load is released and the rider is driving his round.
outForDelivery,
/// At a customer's door — not at a source. The distinction matters: the
/// pickup [arrived] and this one are different places, different actions and
/// different next steps.
deliveryArrived,
/// Handed over. Terminal for a milk-run stop, the way [picked] is terminal
/// for a logistics one.
delivered,
skipped, skipped,
cancelled, cancelled,
rejected, rejected,
@@ -33,17 +65,88 @@ StopStatus stopStatusFromRaw(dynamic raw) {
case 'miler_assigned': case 'miler_assigned':
case 'pending': case 'pending':
return StopStatus.assigned; return StopStatus.assigned;
// ── The backend's own word for accepted ──
//
// Confirmed by the backend team: `pickup_scheduled` is what a booking sits
// on once the rider has accepted it. It parsed as [StopStatus.unknown] —
// so the rung the pickup UI reads had nothing in it, and any *other* field
// that did parse won by default.
case 'pickup_scheduled':
case 'pickupscheduled':
case 'accepted': case 'accepted':
return StopStatus.accepted; return StopStatus.accepted;
case 'active': case 'active':
return StopStatus.active; return StopStatus.active;
case 'arrived': case 'arrived':
// ── The server's own spellings for the same rung ──
//
// `Arrived_At_Pickup` is what `POST /miler/bookings/:id/reached` now
// persists, and `At_Customer` is the undocumented variant the backend was
// observed sending before it. Neither parsed: both fell to
// [StopStatus.unknown], so the rung the rider had just reported would have
// been dropped the moment the backend started returning it — the same
// symptom the local compatibility store exists to paper over, arriving by
// a different route.
//
// `ApiConfig.legacyStatusFromNew` already folds this into `arrived` for
// rows that come through the booking adapter. This is the other door: a raw
// status read straight off a row, which is what `_fetchQueues` does.
//
// ── `At_Customer` — settled, and it means arrived at the PICKUP ──
//
// This word was read two ways: `ApiConfig` folded it to *arrived at
// pickup*, and the delivery case further down this switch folded it to
// [StopStatus.deliveryArrived]. Both could not be right, and a wrong guess
// moves a stop between the two halves of the rider's day — so it was left
// unparsed rather than settled on a hunch.
//
// The backend team answered it: `At_Customer` is a
// `milerprofiles.availabilitystatus` value, not a booking or consignment
// state, so it says where the **rider** is and not where the parcel is. The
// only thing that writes it is `POST /miler/bookings/:id/reached` — the
// pickup-arrival action. Nothing sets it on a delivery leg; a rider heading
// to a receiver goes `On_Delivery`. The name is misleading and predates the
// current lifecycle.
//
// So it belongs here, with the other spellings of the pickup arrival.
// `ApiConfig` was right and this file was wrong.
case 'arrived_at_pickup':
case 'arrivedatpickup':
case 'at customer':
case 'at_customer':
return StopStatus.arrived; return StopStatus.arrived;
// ── The two words that mean the pickup is done ──
//
// Also confirmed by the backend team, and also unparsed until now:
// `converted_to_consignment` is what the booking becomes when
// `pickup-complete` converts it, and `picked_up` is the same fact said
// plainly. Both are the **pickup milestone**, and both must outrank
// whatever the *delivery* lifecycle has moved on to — see
// [MilkRun.stageOf], which is where that precedence lives.
//
// This is the other half of "Picked showed as Active": with these
// unparsed, `active` was the only word on the row the app could read.
case 'converted_to_consignment':
case 'convertedtoconsignment':
case 'picked_up':
case 'picked': case 'picked':
case 'picked up': case 'picked up':
case 'pickuped': case 'pickuped':
case 'pickedup': case 'pickedup':
return StopStatus.picked; return StopStatus.picked;
// The milk run's delivery rungs. `out_for_delivery` is also the backend's
// own consignment status, spelled its way, so a stop stamped from either
// side folds to the same state.
case 'outfordelivery':
case 'out_for_delivery':
case 'out for delivery':
case 'delivering':
return StopStatus.outForDelivery;
case 'deliveryarrived':
case 'delivery_arrived':
return StopStatus.deliveryArrived;
case 'delivered':
return StopStatus.delivered;
case 'skipped': case 'skipped':
return StopStatus.skipped; return StopStatus.skipped;
case 'cancelled': case 'cancelled':
@@ -67,11 +170,53 @@ extension StopStatusX on StopStatus {
bool get isActive => this == StopStatus.active; bool get isActive => this == StopStatus.active;
bool get isRejected => this == StopStatus.rejected; bool get isRejected => this == StopStatus.rejected;
/// A completed pickup — picked up or cancelled. (Matches the long-standing /// On the customer round: released, at a door, or handed over.
/// `!= 'picked' && != 'picked up' && != 'cancelled'` filter used to build the bool get isDeliveryLeg =>
/// "next stops" and remaining-pickups lists.) this == StopStatus.outForDelivery ||
bool get isFinishedPickup => this == StopStatus.deliveryArrived ||
this == StopStatus.picked || this == StopStatus.cancelled; this == StopStatus.delivered;
/// Handed to the customer. Terminal on a milk run.
bool get isDelivered => this == StopStatus.delivered;
/// ── "Is this stop finished?" is a question about the LINE ──
///
/// This is the distinction that broke the milk run, so it is worth being
/// exact about.
///
/// On **logistics**, collecting the parcel *is* the job: [picked] is the end,
/// the booking becomes a consignment, and the hub takes it from there.
///
/// On a **milk run**, [picked] is the *middle of the morning*. The rider is
/// holding fifteen lunches and has not delivered one of them. His day ends at
/// [delivered], one customer at a time.
///
/// One boolean answered both, and it answered "picked = finished". So a
/// milk-run order collected at a kitchen was dropped from the deliveries list
/// as completed work and filed on Activity as history — before the rider had
/// left the counter. He collected five lunches and watched them disappear
/// into his own history.
///
/// Read this, not [isTerminal], anywhere the question is "should this stop
/// still be worked today?".
bool get isWorkComplete => ServiceProfile.active.deliversToCustomer
? (this == StopStatus.delivered || this == StopStatus.cancelled)
: (this == StopStatus.picked ||
this == StopStatus.delivered ||
this == StopStatus.cancelled);
/// Every state that ends a stop on *some* line, without asking which.
///
/// Only for code that must not depend on the active profile — a pure store
/// helper, say. Screens want [isWorkComplete].
bool get isTerminal =>
this == StopStatus.picked ||
this == StopStatus.delivered ||
this == StopStatus.cancelled;
/// The old name for [isWorkComplete], kept because ~8 call sites read it and
/// they all want the line-aware answer.
bool get isFinishedPickup => isWorkComplete;
/// Still awaiting the rider's acceptance — belongs on Home, not Bookings. /// Still awaiting the rider's acceptance — belongs on Home, not Bookings.
bool get isPending => bool get isPending =>
@@ -94,12 +239,68 @@ extension StopStatusX on StopStatus {
StopStatus.accepted => 'Accepted', StopStatus.accepted => 'Accepted',
StopStatus.active => 'In progress', StopStatus.active => 'In progress',
StopStatus.arrived => 'At the stop', StopStatus.arrived => 'At the stop',
StopStatus.picked => 'Completed', StopStatus.picked => 'Picked up',
StopStatus.outForDelivery => 'Out for delivery',
StopStatus.deliveryArrived => 'At the customer',
StopStatus.delivered => 'Delivered',
StopStatus.skipped => 'Skipped', StopStatus.skipped => 'Skipped',
StopStatus.cancelled => 'Cancelled', StopStatus.cancelled => 'Cancelled',
StopStatus.rejected => 'Rejected', StopStatus.rejected => 'Rejected',
StopStatus.unknown => 'Active', StopStatus.unknown => 'Active',
}; };
/// The colour that name is drawn in.
///
/// Next to [label] for the same reason the labels are here: a status cannot
/// be added without somebody deciding both what it is called and how loud it
/// is. Semantic, not decorative — green means the rider is done with it, red
/// means it needs him now, grey means it is waiting on somebody else.
Color get color => switch (this) {
StopStatus.newStop || StopStatus.assigned => ColorConstants.secondaryText,
StopStatus.accepted => ColorConstants.acceptGreen,
StopStatus.active || StopStatus.arrived => ColorConstants.pickupAccent,
StopStatus.picked => ColorConstants.acceptGreen,
// ── Loud, but in the leg's own colour ──
//
// These wore the brand red, and on the stop sheet that word sat between a
// blue leg disc and a blue "what to do here" chip, one line above a red
// Navigate button — a status dressed as an action, on a screen that codes
// its delivery leg blue everywhere else. The round is still drawn loudly;
// it is drawn in the ink that already means *delivery leg*, which is the
// same rule that keeps the pickup-leg states on the pickup accent above.
StopStatus.outForDelivery ||
StopStatus.deliveryArrived => ColorConstants.deliveryAccent,
StopStatus.delivered => ColorConstants.acceptGreen,
StopStatus.skipped => ColorConstants.warning,
StopStatus.cancelled || StopStatus.rejected => ColorConstants.errorRed,
StopStatus.unknown => ColorConstants.secondaryText,
};
/// A glyph for the same state, so the tag never depends on colour alone —
/// these are read in sunlight, through a scratched screen, at a gate.
IconData get icon => switch (this) {
StopStatus.newStop || StopStatus.assigned => LucideIcons.clock,
StopStatus.accepted => LucideIcons.circleCheck,
StopStatus.active => LucideIcons.bike,
StopStatus.arrived => LucideIcons.mapPin,
StopStatus.picked => LucideIcons.package,
StopStatus.outForDelivery => LucideIcons.truck,
StopStatus.deliveryArrived => LucideIcons.mapPin,
// ── Delivered is settled, not decorated ──
//
// `verified_rounded` is a starburst badge — the shape Material reserves
// for *verified account*, and the loudest glyph in the set. Down a column
// of finished stops it made every completed delivery look like an award.
// A package with a tick on it says the same thing about the same object,
// quietly, and it is the mark every delivery app in the world uses.
StopStatus.delivered => LucideIcons.packageCheck,
// Skipped must not read as a variant of delivered. A circle with a stroke
// through it is the "attempted, did not happen" mark; `replay` promised a
// retry the rider may not actually be able to make.
StopStatus.skipped => LucideIcons.circleSlash,
StopStatus.cancelled || StopStatus.rejected => LucideIcons.ban,
StopStatus.unknown => LucideIcons.circle,
};
} }
/// Canonical protocol verbs sent to the status-change API (the *write* side). /// Canonical protocol verbs sent to the status-change API (the *write* side).
@@ -110,4 +311,17 @@ class OrderAction {
static const String picked = 'PICKED'; static const String picked = 'PICKED';
static const String rejected = 'REJECTED'; static const String rejected = 'REJECTED';
static const String cancelled = 'CANCELLED'; static const String cancelled = 'CANCELLED';
/// The milk run's second half. These are *client* verbs — they name what the
/// rider did, and each one maps to a real backend call rather than to a
/// status string the API would not recognise:
///
/// startDelivery → POST /miler/deliveries/start
/// deliveryArrived → (local; the round has no per-stop arrival route)
/// delivered → POST /miler/consignments/:id/deliver
///
/// See the mapping table in `MilkRun`.
static const String startDelivery = 'START_DELIVERY';
static const String deliveryArrived = 'DELIVERY_ARRIVED';
static const String delivered = 'DELIVERED';
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,345 +1,363 @@
import 'dart:async'; import 'dart:async';
import 'dart:isolate'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:miler/helpers/http_overrides.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'dart:isolate';
import 'dart:math' as math; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart'; import 'dart:math' as math;
import 'package:miler/providers/Riderlog/riderlog_provider.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/background/backgroundservice.dart'; import 'package:miler/providers/Riderlog/riderlog_provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:miler/background/backgroundservice.dart';
import 'package:miler/utils/kalman_filter.dart'; import 'package:geolocator/geolocator.dart';
import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/data/device_telemetry.dart';
import 'package:miler/views/helpers/constants/mqtt_constants.dart'; import 'package:miler/data/heartbeat.dart';
import 'package:battery_plus/battery_plus.dart'; import 'package:miler/utils/kalman_filter.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:miler/utils/mqtt_service.dart';
import 'dart:io'; import 'package:miler/views/helpers/constants/mqtt_constants.dart';
import 'package:miler/helpers/http_overrides.dart';
class _BackgroundRiderLog {
class _BackgroundRiderLog { static MilerKalmanFilter? _kf;
static MilerKalmanFilter? _kf; static DateTime? _lastUpdateTime;
static DateTime? _lastUpdateTime;
static Future<Map<String, String>> _ensureLatLng() async {
static Future<Map<String, String>> _ensureLatLng() async { Map<String, String> result = {
Map<String, String> result = { 'lat': '0',
'lat': '0', 'lng': '0',
'lng': '0', 'raw_lat': '0',
'raw_lat': '0', 'raw_lng': '0',
'raw_lng': '0', 'speed': '0',
'speed': '0', 'heading': '0',
'heading': '0', 'velocity_lat': '0',
'velocity_lat': '0', 'velocity_lng': '0',
'velocity_lng': '0', 'status': 'unknown',
'status': 'unknown', 'accuracy': '0',
'accuracy': '0', };
}; try {
try { // 1. Check if location services are enabled
// 1. Check if location services are enabled final serviceEnabled = await Geolocator.isLocationServiceEnabled();
final serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) {
if (!serviceEnabled) { debugPrint('[BG_RIDER_LOG] Location services are disabled.');
debugPrint('[BG_RIDER_LOG] Location services are disabled.'); result['status'] = 'disabled';
result['status'] = 'disabled'; return result;
return result; }
}
// 2. Check permissions
// 2. Check permissions LocationPermission permission = await Geolocator.checkPermission();
LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) {
if (permission == LocationPermission.denied) { debugPrint('[BG_RIDER_LOG] Location permission denied.');
debugPrint('[BG_RIDER_LOG] Location permission denied.'); result['status'] = 'denied';
result['status'] = 'denied'; return result;
return result; }
} if (permission == LocationPermission.deniedForever) {
if (permission == LocationPermission.deniedForever) { debugPrint('[BG_RIDER_LOG] Location permission denied forever.');
debugPrint('[BG_RIDER_LOG] Location permission denied forever.'); result['status'] = 'denied_forever';
result['status'] = 'denied_forever'; return result;
return result; }
}
result['status'] = 'enabled';
result['status'] = 'enabled';
// 3. Get position (using non-deprecated LocationSettings + explicit timeout)
// 3. Get position (using non-deprecated LocationSettings + explicit timeout) final pos = await Geolocator.getCurrentPosition(
final pos = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings(
locationSettings: const LocationSettings( accuracy: LocationAccuracy.high,
accuracy: LocationAccuracy.high, ),
), );
); final now = DateTime.now();
final now = DateTime.now(); double outLat = pos.latitude;
double outLat = pos.latitude; double outLng = pos.longitude;
double outLng = pos.longitude; double speed = pos.speed;
double speed = pos.speed; double heading = pos.heading;
double heading = pos.heading;
// Decompose velocity for Kalman
// Decompose velocity for Kalman final double headingRadians = heading * (math.pi / 180.0);
final double headingRadians = heading * (math.pi / 180.0); final double velocityLng = speed * math.sin(headingRadians);
final double velocityLng = speed * math.sin(headingRadians); final double velocityLat = speed * math.cos(headingRadians);
final double velocityLat = speed * math.cos(headingRadians);
if (_kf == null) {
if (_kf == null) { _kf = MilerKalmanFilter(lat: outLat, lng: outLng);
_kf = MilerKalmanFilter(lat: outLat, lng: outLng); } else {
} else { final double dt = _lastUpdateTime != null
final double dt = _lastUpdateTime != null ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 : 30.0; // Default background interval
: 30.0; // Default background interval _kf!.predict(dt);
_kf!.predict(dt); _kf!.update(outLat, outLng);
_kf!.update(outLat, outLng); outLat = _kf!.x[0];
outLat = _kf!.x[0]; outLng = _kf!.x[1];
outLng = _kf!.x[1]; }
} _lastUpdateTime = now;
_lastUpdateTime = now;
return {
return { 'lat': outLat.toStringAsFixed(6),
'lat': outLat.toStringAsFixed(6), 'lng': outLng.toStringAsFixed(6),
'lng': outLng.toStringAsFixed(6), 'raw_lat': pos.latitude.toStringAsFixed(6),
'raw_lat': pos.latitude.toStringAsFixed(6), 'raw_lng': pos.longitude.toStringAsFixed(6),
'raw_lng': pos.longitude.toStringAsFixed(6), 'speed': speed.toStringAsFixed(2),
'speed': speed.toStringAsFixed(2), 'heading': heading.toStringAsFixed(2),
'heading': heading.toStringAsFixed(2), 'velocity_lat': velocityLat.toStringAsFixed(4),
'velocity_lat': velocityLat.toStringAsFixed(4), 'velocity_lng': velocityLng.toStringAsFixed(4),
'velocity_lng': velocityLng.toStringAsFixed(4), 'status': 'enabled',
'status': 'enabled', 'accuracy': pos.accuracy.toStringAsFixed(1),
'accuracy': pos.accuracy.toStringAsFixed(1), };
}; } catch (e) {
} catch (e) { debugPrint('[BG_RIDER_LOG] Error getting location: $e');
debugPrint('[BG_RIDER_LOG] Error getting location: $e'); return result;
return result; }
} }
}
static String _two(int n) => n.toString().padLeft(2, '0');
static String _two(int n) => n.toString().padLeft(2, '0'); static String _formatDateTimeFull(DateTime dt) {
static String _formatDateTimeFull(DateTime dt) { final y = dt.year.toString();
final y = dt.year.toString(); final m = _two(dt.month);
final m = _two(dt.month); final d = _two(dt.day);
final d = _two(dt.day); final hh = _two(dt.hour);
final hh = _two(dt.hour); final mm = _two(dt.minute);
final mm = _two(dt.minute); final ss = _two(dt.second);
final ss = _two(dt.second); return "$y-$m-$d $hh:$mm:$ss";
return "$y-$m-$d $hh:$mm:$ss"; }
}
static String _formatTime(DateTime dt) {
static String _formatTime(DateTime dt) { final hh = _two(dt.hour);
final hh = _two(dt.hour); final mm = _two(dt.minute);
final mm = _two(dt.minute); final ss = _two(dt.second);
final ss = _two(dt.second); return "$hh:$mm:$ss";
return "$hh:$mm:$ss"; }
}
static Future<void> createLoginNow() async {
static Future<void> createLoginNow() async { try {
try { final prefs = await SharedPreferences.getInstance();
final prefs = await SharedPreferences.getInstance(); final int onduty = prefs.getInt('onduty') ?? 0;
final int onduty = prefs.getInt('onduty') ?? 0; if (onduty != 1) {
if (onduty != 1) { return;
return; }
} final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); final int? partnerid =
final int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); if ((userid ?? 0) == 0) return;
if ((userid ?? 0) == 0) return;
// Prefer explicit username, then fallback to stored full name or first/last
// Prefer explicit username, then fallback to stored full name or first/last String? username = prefs.getString('username');
String? username = prefs.getString('username'); username ??= prefs.getString('user_name');
username ??= prefs.getString('user_name'); if (username == null || username.trim().isEmpty) {
if (username == null || username.trim().isEmpty) { final first = prefs.getString('firstname') ?? '';
final first = prefs.getString('firstname') ?? ''; final last = prefs.getString('lastname') ?? '';
final last = prefs.getString('lastname') ?? ''; final combined = ('$first $last').trim();
final combined = ('$first $last').trim(); if (combined.isNotEmpty) {
if (combined.isNotEmpty) { username = combined;
username = combined; }
} }
}
// ✅ Check if there are active pickup to set status
// ✅ Check if there are active pickup to set status final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final String riderStatus = hasActivePickups ? 'active' : 'idle';
final now = DateTime.now();
final now = DateTime.now(); final iso = _formatDateTimeFull(now);
final iso = _formatDateTimeFull(now); final loginTime = _formatTime(now);
final loginTime = _formatTime(now); final loc = await _ensureLatLng();
final loc = await _ensureLatLng();
final int? tenantid = prefs.getInt('tenantid');
final int? tenantid = prefs.getInt('tenantid'); final int? locationid = prefs.getInt('locationid');
final int? locationid = prefs.getInt('locationid'); final int? applocationid = prefs.getInt('applocationid');
final int? applocationid = prefs.getInt('applocationid'); final String? userfcmtoken = prefs.getString('userfcmtoken');
final String? userfcmtoken = prefs.getString('userfcmtoken');
final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid');
final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); final String orderId = prefs.getString('current_riding_order_id') ?? '';
final String orderId = prefs.getString('current_riding_order_id') ?? '';
// ── The handset's own state goes IN the heartbeat ──
final payload = { //
"logid": logid ?? 0, // It used to be gathered further down, after this post had already gone,
"userid": userid, // and published to MQTT alone — so the console reading `/miler/logs` had
"partnerid": partnerid, // a position and nothing else, and drew an em dash against Battery,
"shiftid": shiftid, // Connection, GPS Accuracy and Location Service on a rider whose phone
"logdate": iso, // was reporting all four.
"login": loginTime, //
"latitude": loc['lat'] ?? '0', // Read here, once, and spread into the payload below. The MQTT publish
"longitude": loc['lng'] ?? '0', // further down reuses the same values rather than asking again, so the
"raw_latitude": loc['raw_lat'] ?? '0', // two lanes cannot disagree about the same second. See [DeviceTelemetry].
"raw_longitude": loc['raw_lng'] ?? '0', final telemetry = await DeviceTelemetry.read(
"velocity_lat": loc['velocity_lat'] ?? '0', // The fix lookup already resolved this to get a position at all;
"velocity_lng": loc['velocity_lng'] ?? '0', // asking a second time can disagree with itself if the rider toggles
"speed": loc['speed'] ?? '0', // the setting in between.
"heading": loc['heading'] ?? '0', locationService: loc['status'],
"onduty": 1, );
"status": riderStatus,
"contactno": prefs.getString('contactno') ?? '', final payload = {
"tenantid": tenantid ?? 0, "logid": logid ?? 0,
"locationid": locationid ?? 0, "userid": userid,
"applocationid": applocationid ?? 0, "partnerid": partnerid,
"userfcmtoken": userfcmtoken ?? '', "shiftid": shiftid,
"username": (username ?? '').trim(), "logdate": iso,
"orderid": orderId, "login": loginTime,
}; "latitude": loc['lat'] ?? '0',
"longitude": loc['lng'] ?? '0',
final firstName = prefs.getString('firstname') ?? ''; "raw_latitude": loc['raw_lat'] ?? '0',
final lastName = prefs.getString('lastname') ?? ''; "raw_longitude": loc['raw_lng'] ?? '0',
if (firstName.trim().isNotEmpty) { "velocity_lat": loc['velocity_lat'] ?? '0',
payload['firstname'] = firstName.trim(); "velocity_lng": loc['velocity_lng'] ?? '0',
} "speed": loc['speed'] ?? '0',
if (lastName.trim().isNotEmpty) { "heading": loc['heading'] ?? '0',
payload['lastname'] = lastName.trim(); "onduty": 1,
} "status": riderStatus,
"contactno": prefs.getString('contactno') ?? '',
final base = ApiConstants.mainRoute == 'live' "tenantid": tenantid ?? 0,
? ApiConstants.createRiderLogLive "locationid": locationid ?? 0,
: ApiConstants.createRiderLogDev; "applocationid": applocationid ?? 0,
"userfcmtoken": userfcmtoken ?? '',
final provider = CreateRiderLogProvider(); "username": (username ?? '').trim(),
final resp = await provider.createRiderLog(payload); "orderid": orderId,
// What the dispatcher needs when a rider goes quiet: the battery, the
if (resp == null || resp.isEmpty) return; // signal, whether location is even on, and how good the last fix was.
final det = (resp['details'] is Map<String, dynamic>) ...telemetry.toPayload(),
? (resp['details'] as Map<String, dynamic>) // Only a measured one — a `0` reads on the console as a perfect fix.
: resp; if ((loc['accuracy'] ?? '0') != '0') "accuracy": loc['accuracy'],
final newLogId = // This heartbeat is the *background* one — it is what keeps reporting
int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); // while the app is not on screen, which is exactly the state the
await prefs.setInt('logid', newLogId); // console's App State column exists to show.
"is_background": true,
// ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) };
final mqttService = MilerMqttService();
if (!mqttService.isConnected) { final firstName = prefs.getString('firstname') ?? '';
// Use a slightly different client ID for background to avoid kicking the main one off final lastName = prefs.getString('lastname') ?? '';
await mqttService.connect(); if (firstName.trim().isNotEmpty) {
} payload['firstname'] = firstName.trim();
}
if (mqttService.isConnected) { if (lastName.trim().isNotEmpty) {
// Gather Telemetry payload['lastname'] = lastName.trim();
final battery = Battery(); }
final int batteryLevel = await battery.batteryLevel;
final BatteryState batteryState = await battery.batteryState;
final isCharging = final provider = CreateRiderLogProvider();
batteryState == BatteryState.charging || final resp = await provider.createRiderLog(payload);
batteryState == BatteryState.full;
if (resp == null || resp.isEmpty) return;
final connectivity = await Connectivity().checkConnectivity(); final det = (resp['details'] is Map<String, dynamic>)
final String connType = connectivity.isNotEmpty ? (resp['details'] as Map<String, dynamic>)
? connectivity.first.toString().split('.').last : resp;
: 'none'; final newLogId =
int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0);
// 1. Direct Telemetry (Feeding the /full API) await prefs.setInt('logid', newLogId);
mqttService.publish('battery', '$batteryLevel%');
mqttService.publish('charging', isCharging ? 'yes' : 'no'); // ✅ MQTT BACKGROUND PUBLISH ( Lane Split )
mqttService.publish('speed', loc['speed'] ?? '0'); final mqttService = MilerMqttService();
mqttService.publish('connection', connType); if (!mqttService.isConnected) {
mqttService.publish('accuracy', loc['accuracy'] ?? '0'); // Use a slightly different client ID for background to avoid kicking the main one off
await mqttService.connect();
// 2. Alert if Location is Off }
final String locStatus = loc['status'] ?? 'unknown';
if (locStatus != 'enabled') { if (mqttService.isConnected) {
mqttService.publish('alerts', { // The same reading the heartbeat above carried — not a second one.
'userid': userid, // Two reads a second apart can report two batteries, and the console
'username': (username ?? '').trim(), // then shows one number on the live tile and a different one in the
'event': 'location_turned_off', // trail it is drawn from.
'error_type': locStatus, final int batteryLevel = telemetry.battery ?? 0;
'battery': '$batteryLevel%', final bool isCharging = telemetry.isCharging ?? false;
'is_charging': isCharging, final String connType = telemetry.connection ?? 'none';
'connection': connType,
'logdate': iso, // 1. Direct Telemetry (Feeding the /full API)
}); mqttService.publish('battery', '$batteryLevel%');
} mqttService.publish('charging', isCharging ? 'yes' : 'no');
mqttService.publish('speed', loc['speed'] ?? '0');
// 3. Low Battery Alert mqttService.publish('connection', connType);
if (batteryLevel < 15 && !isCharging) { mqttService.publish('accuracy', loc['accuracy'] ?? '0');
mqttService.publish('alerts', {
'userid': userid, // 2. Alert if Location is Off
'username': (username ?? '').trim(), final String locStatus = loc['status'] ?? 'unknown';
'event': 'low_battery_warning', if (locStatus != 'enabled') {
'battery': '$batteryLevel%', mqttService.publish('alerts', {
'logdate': iso, 'userid': userid,
}); 'username': (username ?? '').trim(),
} 'event': 'location_turned_off',
'error_type': locStatus,
// 4. Poor GPS Accuracy Alert 'battery': '$batteryLevel%',
final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0; 'is_charging': isCharging,
if (accuracy > 30) { 'connection': connType,
mqttService.publish('alerts', { 'logdate': iso,
'userid': userid, });
'username': (username ?? '').trim(), }
'event': 'poor_gps_signal',
'accuracy': '${accuracy.toStringAsFixed(1)}m', // 3. Low Battery Alert
'logdate': iso, if (batteryLevel < 15 && !isCharging) {
}); mqttService.publish('alerts', {
} 'userid': userid,
'username': (username ?? '').trim(),
// 5. Lane: Status 'event': 'low_battery_warning',
mqttService.updateStatus( 'battery': '$batteryLevel%',
riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, 'logdate': iso,
); });
}
// 6. Lane: Periodic Log (Comprehensive Snapshot)
mqttService.publishLog('rider_periodic_log', { // 4. Poor GPS Accuracy Alert
'userid': userid, final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0;
'username': username, if (accuracy > 30) {
'logdate': iso, mqttService.publish('alerts', {
'latitude': loc['lat'] ?? '0', 'userid': userid,
'longitude': loc['lng'] ?? '0', 'username': (username ?? '').trim(),
'speed': loc['speed'] ?? '0', 'event': 'poor_gps_signal',
'heading': loc['heading'] ?? '0', 'accuracy': '${accuracy.toStringAsFixed(1)}m',
'accuracy': loc['accuracy'] ?? '0', 'logdate': iso,
'status': riderStatus, });
'orderid': orderId, }
'battery': '$batteryLevel%',
'is_charging': isCharging, // 5. Lane: Status
'connection': connType, mqttService.updateStatus(
'location_service': locStatus, riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
'is_background': true, );
});
} // 6. Lane: Periodic Log (Comprehensive Snapshot)
} catch (e) { mqttService.publishLog('rider_periodic_log', {
// ignore background errors 'userid': userid,
} 'username': username,
} 'logdate': iso,
} 'latitude': loc['lat'] ?? '0',
'longitude': loc['lng'] ?? '0',
class RiderLogTaskHandler extends TaskHandler { 'speed': loc['speed'] ?? '0',
Timer? _timer; // not used; plugin provides repeat callback, but keep safety 'heading': loc['heading'] ?? '0',
'accuracy': loc['accuracy'] ?? '0',
@override 'status': riderStatus,
Future<void> onStart(DateTime timestamp, SendPort? sendPort) async { 'orderid': orderId,
// No-op 'battery': '$batteryLevel%',
} 'is_charging': isCharging,
'connection': connType,
@override 'location_service': locStatus,
Future<void> onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { 'is_background': true,
// 1. Rider Log (existing) });
await _BackgroundRiderLog.createLoginNow(); }
} catch (e) {
// 2. Pickup Log (new) // ignore background errors
await BackgroundCollectionLog.processActivePickups(); }
}
// 3. Auto Shift End (new) }
await BackgroundCollectionLog.checkShiftEnd();
} class RiderLogTaskHandler extends TaskHandler {
Timer? _timer; // not used; plugin provides repeat callback, but keep safety
@override
Future<void> onDestroy(DateTime timestamp, SendPort? sendPort) async { @override
_timer?.cancel(); Future<void> onStart(DateTime timestamp, SendPort? sendPort) async {
_timer = null; // No-op
} }
}
@override
@pragma('vm:entry-point') Future<void> onRepeatEvent(DateTime timestamp, SendPort? sendPort) async {
void riderLogCallback() { // 1. Rider Log (existing)
HttpOverrides.global = MyHttpOverrides(); await _BackgroundRiderLog.createLoginNow();
FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler());
} // 2. Pickup Log (new)
await BackgroundCollectionLog.processActivePickups();
// 3. Auto Shift End (new)
await BackgroundCollectionLog.checkShiftEnd();
}
@override
Future<void> onDestroy(DateTime timestamp, SendPort? sendPort) async {
_timer?.cancel();
_timer = null;
}
}
@pragma('vm:entry-point')
void riderLogCallback() {
HttpOverrides.global = MyHttpOverrides();
FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler());
}

View File

@@ -1,8 +1,8 @@
import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/providers/auth/auth_provider.dart'; import 'package:miler/providers/auth/auth_provider.dart';
@@ -10,8 +10,37 @@ import 'package:miler/utils/device.dart';
import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/controllers/profile_controller.dart';
import 'package:miler/Models/login/login.dart'; import 'package:miler/Models/login/login.dart';
import 'package:miler/data/api_config.dart'; import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
enum AuthNext { verifyPin, otp, notRegistered, error } /// Which screen the phone number on the sign-in form has earned.
///
/// ── `otp` is gone, and it was never real ──
///
/// There is no OTP route on the miler side — `MilerApi` carries the whole auth
/// surface and it is login / set-pin / verify-pin / device-token. The old `otp`
/// branch fired when the directory said "no such account", sent the rider to a
/// code screen that verified nothing (`verifyOtp` returned `true` without
/// checking), and dead-ended at a Create-MPIN screen that could not write a
/// PIN. A rider who took it could not come back.
///
/// The server answers this question directly now — see [MilerApi.pinSetOf].
enum AuthNext {
/// `pin_set: true` — he has a PIN. Enter-PIN, exactly as before.
verifyPin,
/// `pin_set: false` — a rider who has never signed in. Set-PIN.
setPin,
/// 404. No miler account on this number.
notRegistered,
/// 403. The row exists but is not an active miler.
inactive,
/// The directory could not be reached. Not evidence about the rider.
error,
}
class AuthController extends GetxController { class AuthController extends GetxController {
final RxBool sendingOtp = false.obs; final RxBool sendingOtp = false.obs;
@@ -20,17 +49,38 @@ class AuthController extends GetxController {
AuthNext? lastDecision; AuthNext? lastDecision;
// Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry" // Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry"
VoidCallback? onPinRetry; VoidCallback? onPinRetry;
/// Why the last [verifyPinWithServer] failed, in the rider's words.
///
/// ── "Incorrect MPIN" was the answer to every question ──
///
/// The MPIN screen painted that one line whenever the controller reported a
/// failure — a wrong PIN, a dead network, a 500, and (for a long time) a
/// device-id lookup that threw before the request was sent. So the one
/// symptom a rider could report was the one cause that was often not true,
/// and there was no way to tell a mistyped PIN from an app that was never
/// going to reach the server.
///
/// Set on every failure path, cleared on success. Read by `Mpin.dart`.
String? lastPinFailure;
static const String _prefsUserIdKey = 'userid'; static const String _prefsUserIdKey = 'userid';
static const String _prefsPendingPinUserIdKey = 'pending_pin_userid'; static const String _prefsPendingPinUserIdKey = 'pending_pin_userid';
static const String _prefsUserNameKey = 'user_name'; static const String _prefsUserNameKey = 'user_name';
static const String _prefsUserEmailKey = 'user_email'; static const String _prefsUserEmailKey = 'user_email';
static const String _prefsContactNoKey = 'contactno'; static const String _prefsContactNoKey = 'contactno';
static const String _prefsAddressKey = 'user_address'; static const String _prefsAddressKey = 'user_address';
static const String _prefsForceMasterPinKey = 'force_master_pin'; // ── The master-PIN constants are gone ──
static const String _masterPinValue = '1234'; //
static const String forceMasterPinPrefKey = _prefsForceMasterPinKey; // `_masterPinValue = '1234'`, `masterPinValue`, `forceMasterPinPrefKey` and
static const String masterPinValue = _masterPinValue; // `_forceMasterPinFlow` were declared here and read by nothing — the feature
bool _forceMasterPinFlow = false; // they belonged to was removed and its constants were not. A public constant
// named `masterPinValue` holding a four-digit PIN is an invitation to the
// next person looking for a shortcut, and it read as though the app still had
// a back door. Removed with the set-PIN work rather than left to be
// rediscovered.
//
// Riders set their own PIN now; `Creat_mpin.dart` refuses `1234` and `1111`
// along with every other trivial sequence.
Future<void> _notifyProfileController() async { Future<void> _notifyProfileController() async {
try { try {
if (Get.isRegistered<ProfileController>()) { if (Get.isRegistered<ProfileController>()) {
@@ -56,54 +106,31 @@ class AuthController extends GetxController {
} }
void _showBottomSheet({required String title, required String message}) { void _showBottomSheet({required String title, required String message}) {
// `Get.bottomSheet` stays (this controller has no BuildContext for the
// kit's presenter), but the surface inside it is the kit's — the same
// glass, handle and insets as every sheet after sign-in, so the first
// sheet a rider ever meets is not the one drawn differently.
Get.bottomSheet( Get.bottomSheet(
Builder( MilerSheetScaffold(
builder: (context) => Container( child: Column(
padding: EdgeInsets.only( mainAxisSize: MainAxisSize.min,
left: 16, crossAxisAlignment: CrossAxisAlignment.stretch,
right: 16, children: [
top: 16, MilerSheetHeader(
bottom: 16 + MediaQuery.of(context).viewPadding.bottom, title: title,
), subtitle: message,
decoration: BoxDecoration( icon: LucideIcons.info,
color: ColorConstants.pureSurface, ),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), const SizedBox(height: 18),
), MilerButton(
child: Column( label: 'Retry',
mainAxisSize: MainAxisSize.min, onPressed: () {
crossAxisAlignment: CrossAxisAlignment.center, Get.back();
children: [ // If MPIN screen has registered a retry callback, run it
Icon(Icons.info_outline, color: ColorConstants.primary, size: 40), onPinRetry?.call();
const SizedBox(height: 12), },
Text( ),
title, ],
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
fontSize: 20,
),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 16),
MilerButton(
label: 'Retry',
onPressed: () {
Get.back();
// If MPIN screen has registered a retry callback, run it
onPinRetry?.call();
},
),
],
),
), ),
), ),
isScrollControlled: true, isScrollControlled: true,
@@ -111,6 +138,26 @@ class AuthController extends GetxController {
); );
} }
/// Which screen this phone number has earned, asked of the server.
///
/// ── One call, one boolean, no guessing ──
///
/// This used to ask `milerAccountExists`, which read *only the status code*
/// of `POST /miler/login` and threw the body away. From "an account exists"
/// it inferred Enter-PIN, and from "it does not" it inferred an OTP branch
/// that verified nothing and dead-ended at a screen which could not write a
/// PIN. A `null` — the directory unreachable — was read as "he has an
/// account", because the OTP direction was the worse place to be wrong.
///
/// The server answers directly now. `pin_set` is the whole decision, and it
/// is read as a boolean rather than off the message beside it, which is prose
/// and will be reworded.
///
/// The fallback when the field is absent — an older server, or a body that
/// did not parse — is **Enter-PIN**, for the same reason the old `null` case
/// chose it: a rider who does have a PIN can sign in, and one who does not
/// gets a refusal he can report. Sending him to Set-PIN on a guess earns a
/// 409 and a screen he cannot leave.
Future<AuthNext> precheckPhone(String phone) async { Future<AuthNext> precheckPhone(String phone) async {
try { try {
final normalized = _normalizePhone(phone); final normalized = _normalizePhone(phone);
@@ -119,25 +166,34 @@ class AuthController extends GetxController {
// The mocked "Demo Rider" (userid 9999) that used to be written here is // The mocked "Demo Rider" (userid 9999) that used to be written here is
// gone. It bypassed the server entirely and left a fake identity in prefs // gone. It bypassed the server entirely and left a fake identity in prefs
// that outlived the session it was created for — every screen reading // that outlived the session it was created for. The real user is
// 'userid' got 9999 until the app was reinstalled. The real user is // established by verify-pin / set-pin and nowhere else.
// established by verify-pin and nowhere else.
await prefs.setString(_prefsContactNoKey, normalized); await prefs.setString(_prefsContactNoKey, normalized);
// On the live backend, ask whether this phone already belongs to an final res = await MilerApi.login(normalized);
// active miler account with a PIN on file. If it does, go straight to the debugPrint(
// MPIN screen: OTP delivery isn't live yet, and the OTP path ends at '[AUTH][PRECHECK] $normalized -> ${res.status} '
// Create-MPIN, which would overwrite the PIN the account was issued. 'pin_set=${MilerApi.pinSetOf(res)} raw=${res.raw}',
// Seeded development accounts take exactly this branch — enter the phone, );
// enter the seeded MPIN, done.
final exists = await _api.milerAccountExists(normalized); if (res.status == 404) {
if (exists) { lastDecision = AuthNext.notRegistered;
lastDecision = AuthNext.verifyPin; return lastDecision!;
}
if (res.status == 403 || res.status == 401) {
lastDecision = AuthNext.inactive;
return lastDecision!;
}
if (!res.ok) {
// 5xx, a timeout, a body that did not parse. Not a fact about the
// rider, and not a reason to send him anywhere final.
lastDecision = AuthNext.error;
return lastDecision!; return lastDecision!;
} }
// Unknown number (or legacy backend) — fall through to the OTP step. lastDecision = MilerApi.pinSetOf(res) == false
lastDecision = AuthNext.otp; ? AuthNext.setPin
: AuthNext.verifyPin;
return lastDecision!; return lastDecision!;
} catch (e) { } catch (e) {
debugPrint('Precheck phone error: $e'); debugPrint('Precheck phone error: $e');
@@ -146,6 +202,13 @@ class AuthController extends GetxController {
} }
} }
/// Why the last [setPin] failed, in the rider's words. Null on success.
String? lastSetPinFailure;
/// True when [setPin] was refused because the account already has a PIN —
/// the caller sends the rider to Enter-PIN rather than showing an error.
bool lastSetPinWasAlreadySet = false;
Future<bool> sendOtp([String? phoneArg]) async { Future<bool> sendOtp([String? phoneArg]) async {
if (sendingOtp.value) return false; if (sendingOtp.value) return false;
if (phoneArg != null && phoneArg.isNotEmpty) { if (phoneArg != null && phoneArg.isNotEmpty) {
@@ -160,62 +223,150 @@ class AuthController extends GetxController {
} }
} }
/// ── There is no OTP route on the backend ──
///
/// This returned true with the comment "automatically succeed for mocked
/// login", which read as leftover demo scaffolding. It is not: `MilerApi`
/// carries the whole auth surface and it is three routes — `login`,
/// `verify-pin`, `device-token`. Nothing verifies a code, so there is nothing
/// for this to call.
///
/// It stays a pass-through for the same reason [AuthProvider.updatePin] does:
/// failing instead would strand a new rider on a screen with no way forward,
/// which is worse and no more honest. What changes is that the gap is now
/// recorded rather than described as a mock, so it shows up in the same place
/// as every other missing route.
Future<bool> verifyOtp(String code) async { Future<bool> verifyOtp(String code) async {
// Automatically succeed for mocked login ApiConfig.logGap(
'verifyOtp',
'No OTP verification route exists; the code entered is not checked.',
);
return true; return true;
} }
/// Creates this rider's PIN and signs him in. `POST /miler/set-pin`.
///
/// ── What this replaces ──
///
/// It called `AuthProvider.updatePin`, which had no route to call and
/// returned a manufactured `403 "Your MPIN is issued by your office and
/// cannot be changed from the app."` — correct while `reset-pin` was the only
/// PIN write and it needed an admin token, and a dead end for the rider
/// standing on the Create-MPIN screen.
///
/// Riders set their own PIN on first sign-in now. The call returns a **full
/// session**, so this lands the rider logged in — there is no verify-pin
/// afterwards and no second screen.
///
/// Returns true when the session is real. On a `409` — the account already
/// has a PIN — [lastSetPinWasAlreadySet] is set and the caller sends him to
/// Enter-PIN rather than showing him an error he cannot act on.
Future<bool> setPin(String newPin) async { Future<bool> setPin(String newPin) async {
lastSetPinFailure = null;
lastSetPinWasAlreadySet = false;
final phone = currentPhone;
if (phone == null || phone.isEmpty) {
lastSetPinFailure =
'We lost your number. Go back and enter it again.';
return false;
}
if (newPin.length != 4 || int.tryParse(newPin) == null) {
lastSetPinFailure = 'Enter a 4-digit PIN.';
return false;
}
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
int? userId = String deviceId = '';
prefs.getInt(_prefsPendingPinUserIdKey) ?? String fcmToken = '';
prefs.getInt(_prefsUserIdKey); try {
if (newPin.length != 4 || int.tryParse(newPin) == null) { deviceId = await DeviceUtils.ensureDeviceId(prefs);
_showBottomSheet( } catch (e) {
title: 'Invalid PIN', debugPrint('[AUTH] device id unavailable, continuing: $e');
message: 'Please enter a valid 4-digit PIN.', }
); try {
fcmToken = await DeviceUtils.ensureFcmToken(prefs);
} catch (e) {
debugPrint('[AUTH] fcm token unavailable, continuing: $e');
}
// Same rule as verify-pin: only THIS attempt may grant a session, so a
// stale token cannot make a refused set-pin look accepted.
await ApiConfig.clearToken();
final Login res = await _api.loginParsed(
contactNo: phone,
deviceType: Platform.operatingSystem,
configId: 6,
deviceId: deviceId,
fcmToken: fcmToken,
pinRaw: newPin,
firstTime: true,
);
// `_loginNew` normalises the envelope as `code: ok ? 200 : httpStatus`,
// so on a refusal this IS the server's status line. `httpstatus` is on
// the envelope too but `Login` does not parse it.
final int http = res.code ?? 0;
// ── 409 is not a failure the rider can fix by trying again ──
//
// It means the account already has a PIN — he is not a first-time rider
// after all, or he set one on another handset. The caller sends him to
// Enter-PIN; telling him "could not save your PIN" would leave him
// retyping a PIN the server will never accept.
if (http == 409) {
lastSetPinWasAlreadySet = true;
lastSetPinFailure =
'You already have a PIN on this number. Enter it to sign in.';
return false; return false;
} }
if (userId == null) { if (http == 404) {
_showBottomSheet( lastSetPinFailure =
title: 'Error', 'That number is not registered as a Miler. Contact your manager.';
message: 'User ID not found. Please try again.',
);
return false; return false;
} }
// Demo mode: the mocked login flow stores a fake rider id (9999) that if (http == 403 || http == 401) {
// the live server rejects, so updatePin fails. Save the PIN locally and lastSetPinFailure =
// report success so the demo Create-MPIN flow works without a real 'This account is not active. Contact your manager.';
// account or server call. return false;
if (userId == 9999) { }
final bool serverAccepted = res.status == true;
final String? token = await ApiConfig.getToken();
final bool haveSession = token != null && token.isNotEmpty;
if (serverAccepted && !haveSession) {
// The PIN was created and there is nothing to sign in with. An
// integration fault, and it must never be reported as the rider's
// mistake — see the same branch in [verifyPinWithServer].
debugPrint('[AUTH] set-pin succeeded but returned no usable token');
lastSetPinFailure =
'Your PIN was saved, but the server did not return a session. '
'Sign in with your new PIN.';
lastSetPinWasAlreadySet = true;
return false;
}
if (serverAccepted && haveSession) {
await prefs.setString('dbPin', newPin); await prefs.setString('dbPin', newPin);
await prefs.setBool('logged_out', false);
await prefs.setString(_prefsContactNoKey, phone);
await prefs.remove(_prefsPendingPinUserIdKey); await prefs.remove(_prefsPendingPinUserIdKey);
await _notifyProfileController();
return true; return true;
} }
final int pinNum = int.parse(newPin); final String serverMsg = (res.message ?? '').trim();
final res = await _api.updatePin(userId: userId, pin: pinNum); lastSetPinFailure = serverMsg.isNotEmpty && serverMsg.length < 140
if (res.statusCode >= 200 && res.statusCode < 300) { ? serverMsg
await prefs.setString('dbPin', newPin); : 'Could not set your PIN. Check your connection and try again.';
await prefs.remove(_prefsPendingPinUserIdKey);
return true;
}
final bodyPreview = res.body.length > 200
? '${res.body.substring(0, 200)}...'
: res.body;
_showBottomSheet(
title: 'Failed (${res.statusCode})',
message: 'Unable to set PIN. Server said: $bodyPreview',
);
return false; return false;
} catch (e) { } catch (e) {
debugPrint('setPin error: $e'); debugPrint('setPin error: $e');
_showBottomSheet( lastSetPinFailure =
title: 'Error', 'Something went wrong while setting your PIN. Try again.';
message: 'Something went wrong while setting the PIN.',
);
return false; return false;
} }
} }
@@ -281,15 +432,52 @@ class AuthController extends GetxController {
currentPhone ?? prefs.getString(_prefsContactNoKey) ?? ''; currentPhone ?? prefs.getString(_prefsContactNoKey) ?? '';
final int? pinNum = int.tryParse(inputPin); final int? pinNum = int.tryParse(inputPin);
if (phone.isEmpty || pinNum == null || inputPin.length != 4) { if (phone.isEmpty || pinNum == null || inputPin.length != 4) {
_showBottomSheet( // Two different faults wearing one message. A missing phone is not the
title: 'Invalid PIN', // rider mistyping — it means he reached this screen without the number
message: 'Please enter your 4-digit PIN and try again.', // step, and telling him to re-enter his PIN sends him round a loop that
); // cannot end.
lastPinFailure = phone.isEmpty
? 'We lost your phone number. Go back and enter it again.'
: 'Enter all 4 digits of your MPIN.';
_showBottomSheet(title: 'Invalid PIN', message: lastPinFailure!);
return false; return false;
} }
final deviceId = await DeviceUtils.ensureDeviceId(prefs); // ── Nothing gathered here may stop the sign-in ──
final fcmToken = await DeviceUtils.ensureFcmToken(prefs); //
// These two lines used to sit bare inside this `try`, and
// `ensureDeviceId` threw on iOS and on any Android that handed back an
// empty id. The throw landed in the catch below, so the rider was told
// "Could not reach the server" — before a single byte had been sent —
// and the MPIN screen then called it an incorrect PIN. Every number,
// every attempt.
//
// `ensureDeviceId` is total now (see [DeviceUtils]), and this second
// guard says why it must stay that way: `deviceId` is not even part of
// the verify-pin body, and a push token the rider declined is not a
// reason to refuse him his shift. Best effort, then post regardless.
String deviceId = '';
String fcmToken = '';
try {
deviceId = await DeviceUtils.ensureDeviceId(prefs);
} catch (e) {
debugPrint('[AUTH] device id unavailable, continuing: $e');
}
try {
fcmToken = await DeviceUtils.ensureFcmToken(prefs);
} catch (e) {
debugPrint('[AUTH] fcm token unavailable, continuing: $e');
}
// ── This attempt is the only thing that may grant a session ──
//
// The check below asks prefs whether a token exists. Without this line
// that question is answered by *any previous session*, so the gate was
// broken in both directions: a stale token made a rejected PIN look
// accepted, and a fresh install with a token the app could not find made
// an accepted PIN look rejected.
await ApiConfig.clearToken();
final Login res = await _api.loginParsed( final Login res = await _api.loginParsed(
contactNo: phone, contactNo: phone,
deviceType: Platform.operatingSystem, deviceType: Platform.operatingSystem,
@@ -300,9 +488,38 @@ class AuthController extends GetxController {
pinRaw: inputPin, pinRaw: inputPin,
); );
// ── Three outcomes, not two ──
//
// `res.status` is the server's verdict on the credentials. The token is
// whether this call handed back a session. They are different facts, and
// collapsing them into one boolean is what produced **"Login failed"** on
// a PIN the server had just accepted — the app could not find the token
// in the response, so it reported the rider's PIN as wrong.
final bool serverAccepted = res.status == true;
final String? token = await ApiConfig.getToken(); final String? token = await ApiConfig.getToken();
final bool ok = res.status == true && token != null && token.isNotEmpty; final bool haveSession = token != null && token.isNotEmpty;
final bool ok = serverAccepted && haveSession;
if (serverAccepted && !haveSession) {
// The credentials were right and there is nothing to sign in with.
// This is an integration fault, not a rider fault, and it must never
// again be reported as a bad PIN. The log line above it names the keys
// the response actually carried.
debugPrint(
'[AUTH] verify-pin accepted the PIN but returned no usable token',
);
lastPinFailure =
'Your MPIN was accepted, but the server did not return a session. '
'Please report this to your office — it is not your PIN.';
_showBottomSheet(
title: 'Could not start session',
message: lastPinFailure!,
);
return false;
}
if (ok) { if (ok) {
lastPinFailure = null;
await prefs.setString('dbPin', inputPin); await prefs.setString('dbPin', inputPin);
await prefs.setBool('logged_out', false); await prefs.setBool('logged_out', false);
currentPhone = _normalizePhone(phone); currentPhone = _normalizePhone(phone);
@@ -329,19 +546,40 @@ class AuthController extends GetxController {
return true; return true;
} }
_showBottomSheet( // ── Only 401/403 is a statement about the PIN ──
title: 'Login failed', //
message: (res.message != null && res.message!.trim().isNotEmpty) // Everything else — a 502, a gateway timeout, a captive portal, a body
? res.message! // that is not JSON — is the sign-in failing to *complete*, which is a
: 'Incorrect phone number or PIN. Please try again.', // different problem with a different fix. Reporting all of it as a login
); // failure is what made a network fault indistinguishable from a wrong
// MPIN, on a screen whose whole job is to tell those apart.
final int status = res.code ?? 0;
final String serverSaid = (res.message ?? '').trim();
final bool aboutTheCredentials = status == 401 || status == 403;
if (aboutTheCredentials) {
lastPinFailure = serverSaid.isNotEmpty
? serverSaid
: 'Incorrect MPIN for $phone. Try again.';
_showBottomSheet(title: 'Login failed', message: lastPinFailure!);
} else {
lastPinFailure = serverSaid.isNotEmpty
? 'Sign-in could not complete. $serverSaid'
: 'Sign-in could not complete (HTTP $status). This is not your '
'MPIN — check the connection and try again.';
_showBottomSheet(
title: 'Sign-in did not complete',
message: lastPinFailure!,
);
}
return false; return false;
} catch (e) { } catch (e) {
// Never reached the server, or could not read what came back. Whatever
// this is, it is NOT the rider's PIN, and saying so is the whole point.
debugPrint('verifyPinWithServer error: $e'); debugPrint('verifyPinWithServer error: $e');
_showBottomSheet( lastPinFailure =
title: 'Connection error', 'Could not reach the server. Check your connection and try again.';
message: 'Could not reach the server. Check your internet and retry.', _showBottomSheet(title: 'Connection error', message: lastPinFailure!);
);
return false; return false;
} }
} }

View File

@@ -9,7 +9,6 @@ import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/background/foreground_service.dart' as fg; import 'package:miler/background/foreground_service.dart' as fg;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@@ -131,7 +130,6 @@ class LogController extends GetxController {
final List<String> remaining = []; final List<String> remaining = [];
bool anySuccess = false; bool anySuccess = false;
for (final itemStr in queue) { for (final itemStr in queue) {
try { try {
final Map<String, dynamic> item = jsonDecode(itemStr); final Map<String, dynamic> item = jsonDecode(itemStr);

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import 'package:miler/data/miler_api.dart';
/// ///
/// ── Two things this used to do and no longer does ── /// ── Two things this used to do and no longer does ──
/// ///
/// It called a second, legacy backend (`jupiter.doormile.app/.../ /// It called a second, legacy backend (the retired `.../
/// getuserbonussummary`) when the new-API flag was off, and it filled an empty /// getuserbonussummary`) when the new-API flag was off, and it filled an empty
/// balance with seeded demo points in debug builds. Both are gone: there is one /// balance with seeded demo points in debug builds. Both are gone: there is one
/// backend now, and a points balance is a number about somebody's pay — the /// backend now, and a points balance is a number about somebody's pay — the
@@ -29,8 +29,7 @@ class RewardsController extends GetxController {
// earnings. // earnings.
final res = await MilerApi.earnings(period: 'monthly'); final res = await MilerApi.earnings(period: 'monthly');
if (res.ok) { if (res.ok) {
totalPoints.value = totalPoints.value = int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0;
int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0;
} else { } else {
error.value = res.message.isEmpty error.value = res.message.isEmpty
? "Couldn't load your points" ? "Couldn't load your points"

View File

@@ -1,31 +1,159 @@
import 'dart:convert'; import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:http/http.dart' as http; import 'package:miler/data/miler_api.dart';
import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:miler/data/miler_api.dart'; /// ─────────────────────────────────────────────────────────────────────────
import 'package:miler/views/helpers/constants/apiconstants.dart'; /// THE WEEK'S KILOMETRES
///
class RiderWeeklyKmController { /// ── Why the chart was empty ──
final String baseUrl = ApiConstants.summaryriderkmLive; ///
/// This read `data.breakdown` off `GET /miler/earnings?period=weekly` and
/// `GET /miler/earnings?period=weekly` → `data:{ total_kms, breakdown }`. /// mapped it into the seven bars. That field is not on the contract: the
/// /// earnings response carries `completed_stops`, `cancelled_stops`,
/// [userId] is unused: the token identifies the rider, and asking for someone /// `total_stops`, `total_kms`, `total_earnings` and `total_bonus` — six
/// else's kilometres is not a thing the endpoint offers. Kept on the /// **totals for the period asked for**, and no per-day series at all.
/// signature because the provider above passes it and the call sites read ///
/// better for naming whose distance they mean. /// So `breakdown` was always null, the list was always empty, and the chart
Future<Map<String, dynamic>> getRiderWeeklyKms(int userId) async { /// drew seven bars of nothing while the rider had ridden all week. It failed
final res = await MilerApi.earnings(period: 'weekly'); /// silently because an absent key and an empty week look identical downstream.
if (!res.ok) { ///
throw Exception('Failed to fetch (code: ${res.status})'); /// ── What it does instead ──
} ///
final breakdown = res.map['breakdown']; /// The endpoint answers for *a* period, and it takes a `date`. A week is seven
final details = (breakdown is List ? breakdown : const []) /// days, so the week is seven daily calls — asked concurrently, so the page
.whereType<Map>() /// waits for the slowest one rather than the sum of seven.
.map((e) => RiderWeeklyKms.fromJson(Map<String, dynamic>.from(e))) ///
.toList(); /// That is more requests than one, and it is the honest cost of a contract that
return { /// has no series on it. It is bounded (seven, on a screen opened occasionally
'details': details, /// rather than polled), and it is built from the same figure the totals row
'total_kms': (res.map['total_kms'] ?? 0).toDouble(), /// prints, so the bars and the total cannot disagree.
}; ///
} /// **The fast path stays.** If `breakdown` ever ships, it is used and the seven
} /// calls are skipped — that check is two lines and it is what makes this
/// removable later without touching the page.
/// ─────────────────────────────────────────────────────────────────────────
class RiderWeeklyKmController {
/// `Mon` … `Sun`. Written out rather than taken from `intl` because these
/// strings are matched against the server's own day names elsewhere, and a
/// locale-aware formatter would introduce a mismatch that comparison cannot
/// survive.
static const List<String> _days = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
static String _iso(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
static double _num(dynamic v) {
if (v is num) return v.toDouble();
return double.tryParse('${v ?? ''}') ?? 0;
}
/// Reads a weekly `breakdown` into the chart's series, or returns null when
/// there is nothing usable in it.
///
/// ── Null is the whole contract here ──
///
/// Anything short of a series the chart can draw has to fall through to the
/// seven daily calls, because the alternative is what this page shipped for
/// months: an empty list drawn as seven empty bars, indistinguishable from a
/// week with no riding in it. Absent, null, not a list, empty, entries that
/// are not maps, entries with no day or no parseable distance — all of them
/// are "no series", and none of them is a chart.
///
/// ── The day label is normalised ──
///
/// The chart labels its bars with the first three characters of `day`, which
/// works for `Mon` and produces `202` for `2026-08-19`. The backend's example
/// uses the ISO form, so an ISO date is converted to the weekday it names and
/// anything else is passed through — a server that sends `Monday`, `Mon` or
/// `mon` already works, and one that sends something unrecognisable is
/// unusable rather than silently mislabelled.
static List<RiderWeeklyKms>? _readBreakdown(dynamic raw) {
if (raw is! List || raw.isEmpty) return null;
final rows = <RiderWeeklyKms>[];
for (final entry in raw) {
if (entry is! Map) return null;
final day = _dayLabel(entry['day']);
if (day.isEmpty) return null;
final kms = entry['kms'];
if (kms != null && kms is! num && double.tryParse('$kms') == null) {
return null;
}
rows.add(RiderWeeklyKms(day: day, kms: _num(kms)));
}
return rows.isEmpty ? null : rows;
}
/// `2026-08-19` → `Tue`. Any other non-empty string is returned as it came.
static String _dayLabel(dynamic raw) {
final s = raw?.toString().trim() ?? '';
if (s.isEmpty) return '';
final parsed = DateTime.tryParse(s);
if (parsed != null) return _days[parsed.weekday - 1];
return s;
}
/// The last seven days, oldest first, plus the week's total.
///
/// [userId] is unused: the token identifies the rider, and asking for someone
/// else's kilometres is not a thing the endpoint offers. Kept on the
/// signature because the call sites read better for naming whose distance
/// they mean.
Future<Map<String, dynamic>> getRiderWeeklyKms(int userId) async {
final weekly = await MilerApi.earnings(period: 'weekly');
if (!weekly.ok) {
throw Exception('Failed to fetch (code: ${weekly.status})');
}
final weekTotal = _num(weekly.map['total_kms']);
// ── The fast path ──
//
// If the server returns a per-day series, take it and spend no further
// requests. Nothing below runs.
final fast = _readBreakdown(weekly.map['breakdown']);
if (fast != null) {
return {'details': fast, 'total_kms': weekTotal};
}
// Seven days ending today, asked at once.
final today = DateTime.now();
final dates = <DateTime>[
for (var back = 6; back >= 0; back--)
DateTime(today.year, today.month, today.day - back),
];
final results = await Future.wait(
dates.map((d) => MilerApi.earnings(period: 'daily', date: _iso(d))),
);
final details = <RiderWeeklyKms>[];
var summed = 0.0;
for (final (i, res) in results.indexed) {
// A day that failed is a day with no figure, not a zero worth charting
// against the others — but the bar still has to exist or the week is six
// days long and the labels slide. Zero, and the total below is what
// corrects for it.
final km = res.ok ? _num(res.map['total_kms']) : 0.0;
summed += km;
details.add(RiderWeeklyKms(day: _days[dates[i].weekday - 1], kms: km));
}
return {
'details': details,
// The weekly total is the server's own where it has one — the seven daily
// figures are a reconstruction, and a reconstruction should not overrule
// the number the backend computed. It stands in only when the weekly call
// reported nothing.
'total_kms': weekTotal > 0 ? weekTotal : summed,
};
}
}

View File

@@ -1,7 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/Models/riders/riders_models.dart'; import 'package:miler/Models/riders/riders_models.dart';
import 'package:miler/providers/Riderlog/riderlog_provider.dart'; import 'package:miler/providers/Riderlog/riderlog_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -15,6 +14,8 @@ import 'package:miler/controllers/logcontroller.dart';
import 'package:miler/background/foreground_service.dart' as fg; import 'package:miler/background/foreground_service.dart' as fg;
import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/views/helpers/constants/mqtt_constants.dart'; import 'package:miler/views/helpers/constants/mqtt_constants.dart';
import 'package:battery_plus/battery_plus.dart'; import 'package:battery_plus/battery_plus.dart';
import 'package:miler/controllers/connectivity_mixin.dart'; import 'package:miler/controllers/connectivity_mixin.dart';
@@ -169,7 +170,6 @@ class RiderLogController extends GetxController
required String latitude, required String latitude,
required String longitude, required String longitude,
}) async { }) async {
final payload = { final payload = {
"breakid": breakid, "breakid": breakid,
"logid": logid, "logid": logid,
@@ -235,7 +235,7 @@ class RiderLogController extends GetxController
// ✅ Check if there are active pickup to set status // ✅ Check if there are active pickup to set status
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = hasActivePickups ? 'active' : 'idle'; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final String orderId = prefs.getString('current_riding_order_id') ?? ''; final String orderId = prefs.getString('current_riding_order_id') ?? '';
debugPrint( debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Active pickup: $hasActivePickups -> status: $riderStatus', '[RIDERLOG][CREATE LOGIN NOW] Active pickup: $hasActivePickups -> status: $riderStatus',
@@ -261,6 +261,14 @@ class RiderLogController extends GetxController
} }
} catch (_) {} } catch (_) {}
// The handset's own state — see [DeviceTelemetry] for why this is read
// here and not published to MQTT alone. The console's Battery,
// Connection, GPS Accuracy and Location Service columns are read off the
// rider log, and until now nothing put them in it.
final telemetry = await DeviceTelemetry.read(
locationService: loc['status'],
);
final Map<String, dynamic> payload = { final Map<String, dynamic> payload = {
// Base from server template // Base from server template
...baseTemplate, ...baseTemplate,
@@ -289,6 +297,14 @@ class RiderLogController extends GetxController
'applocationid': applocationid ?? 0, 'applocationid': applocationid ?? 0,
'userfcmtoken': userfcmtoken ?? '', 'userfcmtoken': userfcmtoken ?? '',
'orderid': orderId, 'orderid': orderId,
...telemetry.toPayload(),
// Only a measured one. A `0` here would draw on the console as a
// perfect fix rather than as no fix at all.
if ((loc['accuracy'] ?? '').isNotEmpty) 'accuracy': loc['accuracy'],
// This is the *foreground* controller — the rider has the app open.
// The background heartbeat sends true, and the difference between the
// two is what the console's App State column reports.
'is_background': false,
}; };
// --- MQTT LOGIC ( Lane Split ) --- // --- MQTT LOGIC ( Lane Split ) ---
@@ -299,7 +315,7 @@ class RiderLogController extends GetxController
// 1. Lane: Status // 1. Lane: Status
mqttService.updateStatus( mqttService.updateStatus(
riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
); );
// 2. Lane: Profile (Send only if significantly changed or first time) // 2. Lane: Profile (Send only if significantly changed or first time)
@@ -326,6 +342,10 @@ class RiderLogController extends GetxController
'heading': loc['heading'] ?? '0', 'heading': loc['heading'] ?? '0',
'status': riderStatus, 'status': riderStatus,
'orderid': orderId, 'orderid': orderId,
// The same reading the log above carries, so the live lane and the
// trail cannot report two different batteries a second apart.
...telemetry.toPayload(),
if ((loc['accuracy'] ?? '').isNotEmpty) 'accuracy': loc['accuracy'],
}); });
// Ensure rider identity fields are clean: // Ensure rider identity fields are clean:
@@ -428,7 +448,7 @@ class RiderLogController extends GetxController
// Check status for offline fallback too // Check status for offline fallback too
final bool hasActivePickups = final bool hasActivePickups =
prefs.getBool('has_live_pickup') ?? false; prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = hasActivePickups ? 'active' : 'idle'; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final Map<String, dynamic> payload = { final Map<String, dynamic> payload = {
...baseTemplate, ...baseTemplate,
@@ -471,7 +491,6 @@ class RiderLogController extends GetxController
payload.remove('lastname'); payload.remove('lastname');
} }
await _saveToOfflineQueue('/miler/logs', payload); await _saveToOfflineQueue('/miler/logs', payload);
} }
} catch (e) { } catch (e) {
@@ -488,15 +507,6 @@ class RiderLogController extends GetxController
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
if ((userid ?? 0) == 0) return false; if ((userid ?? 0) == 0) return false;
// Demo mode: the mocked login flow in auth.dart stores a fake rider id
// (9999) that the live backend rejects, so the real updateriderlog call
// always fails. Toggle duty locally and report success so the demo
// "Slide to Start Duty" works without a real account or server call.
if (userid == 9999) {
await prefs.setInt('onduty', on ? 1 : 0);
return true;
}
final loc = await _ensureLatLng('0', '0'); final loc = await _ensureLatLng('0', '0');
final payload = RiderUpdate( final payload = RiderUpdate(
userid: userid, userid: userid,
@@ -510,10 +520,19 @@ class RiderLogController extends GetxController
await prefs.setInt('onduty', on ? 1 : 0); await prefs.setInt('onduty', on ? 1 : 0);
if (on) { if (on) {
await createLoginNowV2(); await createLoginNowV2();
final int interval = prefs.getInt('logseconds') ?? 0; // ── Going on duty always starts the heartbeat ──
if (interval > 0) { //
startAutoCreateLoginLoop(seconds: interval); // This was gated on `interval > 0`, reading a stored zero as a
} // decision not to beat. The v1 login has no `logseconds` to store, so
// the gate was shut on every rider: duty went on, one log was posted
// by the call above, and nothing followed it — no telemetry trail and
// no location writes for the rest of the shift.
//
// There is no cadence at which a rider on duty should not report, so
// there is no branch here any more. [resolveLogSeconds] supplies the
// floor when the hub has not set one.
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
startAutoCreateLoginLoop(seconds: interval);
// Ensure foreground logging notification is started when going on-duty // Ensure foreground logging notification is started when going on-duty
try { try {
@@ -580,15 +599,45 @@ class RiderLogController extends GetxController
} }
} }
/// Whether the periodic heartbeat is currently running on the stream path.
///
/// ── Why a test needs to be able to see this ──
///
/// The `logseconds` bug was invisible from outside the class. Duty went on,
/// the API accepted it, one log was posted by the immediate call — and the
/// loop that was supposed to follow it simply never started. Nothing threw,
/// nothing logged an error, and the only symptom was a console panel that had
/// been blank for so long it read as a backend problem.
///
/// Android normally runs the heartbeat as a foreground service instead, and
/// leaves this null; the test host is never Android, so the stream path is
/// the one under test. See [startAutoCreateLoginLoop].
@visibleForTesting
bool get autoLoopRunning => _autoLoginSubscription != null;
// Start periodic createRiderLog calls based on seconds (or prefs 'logseconds') // Start periodic createRiderLog calls based on seconds (or prefs 'logseconds')
// ✅ CRITICAL: When there are active pickup, use 30 seconds (same as pickup logs) // ✅ CRITICAL: When there are active pickup, use 30 seconds (same as pickup logs)
// Otherwise, use the configured logseconds interval // Otherwise, use the configured logseconds interval
void startAutoCreateLoginLoop({int? seconds}) async { /// `Future<void>`, not `void`. It was fire-and-forget, which meant no caller
/// could wait for the loop to actually be up — and the one place that most
/// wanted to, [setOnDuty], had no way to tell whether the heartbeat it had
/// just asked for existed. Every existing call site ignores the future and is
/// unchanged by this.
Future<void> startAutoCreateLoginLoop({int? seconds}) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// ✅ Check if there are active pickup - if yes, use 30 seconds (same as pickup logs) // ✅ Check if there are active pickup - if yes, use 30 seconds (same as pickup logs)
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final int baseInterval = seconds ?? (prefs.getInt('logseconds') ?? 0); // ── The configured cadence, or the floor under it ──
//
// `?? 0` here was the second half of the same bug as the one in
// [setOnDuty]: a caller that passes no `seconds` — the app-resume path in
// `main.dart` is one — fell through to a stored zero and then returned at
// the `interval <= 0` guard below, so the loop the resume was trying to
// revive silently did not start. See [resolveLogSeconds].
final int baseInterval = resolveLogSeconds(
seconds ?? prefs.getInt('logseconds'),
);
// When there are active pickup, post rider logs every 30 seconds (matching pickup logs) // When there are active pickup, post rider logs every 30 seconds (matching pickup logs)
// Otherwise, use the configured interval // Otherwise, use the configured interval
@@ -600,12 +649,20 @@ class RiderLogController extends GetxController
); );
} }
await stopAutoCreateLoginLoop(); // Cancel existing subscription // Stop before start, which is what keeps this method safe to call from the
// three places that do — going on duty, resuming the app, and a pickup
// going live. Whichever arrives last wins, and there is never a second
// loop: the subscription is cancelled and the foreground service stopped
// before either is created again.
await stopAutoCreateLoginLoop();
// Attempt to flush offline logs on loop start // Attempt to flush offline logs on loop start
flushOfflineLogs(); flushOfflineLogs();
if (interval <= 0) return; // No `interval <= 0` guard. [resolveLogSeconds] cannot return one, and the
// guard that used to be here is what turned a missing `logseconds` into a
// rider who never reported. Duty state decides whether the loop runs; the
// cadence only decides how often.
final int onduty = prefs.getInt('onduty') ?? 0; final int onduty = prefs.getInt('onduty') ?? 0;
if (onduty != 1) { if (onduty != 1) {
debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty'); debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty');
@@ -790,6 +847,19 @@ class RiderLogController extends GetxController
'heading': '0', 'heading': '0',
'velocity_lat': '0', 'velocity_lat': '0',
'velocity_lng': '0', 'velocity_lng': '0',
// ── Two fields the background lookup has always returned and this one
// did not ──
//
// `accuracy` is how good the fix is and `status` is whether location is
// switched on at all — the two the console draws under GPS Accuracy and
// Location Service. Absent here, the foreground heartbeat had nothing to
// send and would have had to invent a `0`, which reads as a *perfect*
// fix rather than as no fix.
//
// Empty, not zero: an unknown accuracy is dropped from the payload by
// the caller and drawn as an em dash, which is the truth.
'accuracy': '',
'status': 'unknown',
}; };
try { try {
final needsFetch = final needsFetch =
@@ -797,7 +867,10 @@ class RiderLogController extends GetxController
if (!needsFetch) return result; if (!needsFetch) return result;
final serviceEnabled = await Geolocator.isLocationServiceEnabled(); final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return result; if (!serviceEnabled) {
result['status'] = 'disabled';
return result;
}
LocationPermission permission = await Geolocator.checkPermission(); LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
@@ -805,8 +878,12 @@ class RiderLogController extends GetxController
} }
if (permission == LocationPermission.denied || if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) { permission == LocationPermission.deniedForever) {
result['status'] = permission == LocationPermission.deniedForever
? 'denied_forever'
: 'denied';
return result; return result;
} }
result['status'] = 'enabled';
final Position pos = await Geolocator.getCurrentPosition( final Position pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings( locationSettings: const LocationSettings(
@@ -847,6 +924,8 @@ class RiderLogController extends GetxController
'heading': heading.toStringAsFixed(2), 'heading': heading.toStringAsFixed(2),
'velocity_lat': velocityLat.toStringAsFixed(4), 'velocity_lat': velocityLat.toStringAsFixed(4),
'velocity_lng': velocityLng.toStringAsFixed(4), 'velocity_lng': velocityLng.toStringAsFixed(4),
'accuracy': pos.accuracy.toStringAsFixed(1),
'status': 'enabled',
}; };
} catch (_) { } catch (_) {
return result; return result;
@@ -898,7 +977,6 @@ class RiderLogController extends GetxController
final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16 final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16
final breakstart = _formatTime(now); // e.g. 16:36:16 final breakstart = _formatTime(now); // e.g. 16:36:16
// Build payload with all required fields in the exact format the API expects // Build payload with all required fields in the exact format the API expects
final payload = <String, dynamic>{ final payload = <String, dynamic>{
"breakid": localBreakId, "breakid": localBreakId,
@@ -1018,9 +1096,6 @@ class RiderLogController extends GetxController
final breakhoursDouble = duration.inSeconds / 3600.0; final breakhoursDouble = duration.inSeconds / 3600.0;
// Debug logs for terminal visibility // Debug logs for terminal visibility
debugPrint(
'[BREAK][UPDATE] URL: ${ApiConstants.mainRoute == 'live' ? ApiConstants.updateBreakRiderLogLive : ApiConstants.updateBreakRiderLogDev}',
);
debugPrint( debugPrint(
'[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude', '[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude',
); );
@@ -1074,10 +1149,9 @@ class RiderLogController extends GetxController
final iso = _formatDateTimeFull(now); final iso = _formatDateTimeFull(now);
final loginTime = _formatTime(now); final loginTime = _formatTime(now);
// ✅ Check if there are active pickup to set status // ✅ Check if there are active pickup to set status
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = hasActivePickups ? 'active' : 'idle'; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
// Resolve rider display name (username) from prefs to include in log // Resolve rider display name (username) from prefs to include in log
String? username = prefs.getString('username'); String? username = prefs.getString('username');

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,10 @@
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:miler/data/api_status.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/route_order.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// Base URL, bearer token, and the adapter that turns a v1 booking into the /// Base URL, bearer token, and the adapter that turns a v1 booking into the
@@ -7,7 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart';
/// ── The flag is gone ── /// ── The flag is gone ──
/// ///
/// This class used to carry `useNewApi`, a `--dart-define` switch between the /// This class used to carry `useNewApi`, a `--dart-define` switch between the
/// v1 backend and a legacy one (`jupiter.doormile.app` / `queue.workolik.com`). /// v1 backend and a legacy one, on two now-retired hosts.
/// Every provider branched on it, so the app shipped two implementations of /// Every provider branched on it, so the app shipped two implementations of
/// every call and only one of them was ever exercised. /// every call and only one of them was ever exercised.
/// ///
@@ -59,6 +65,62 @@ class ApiConfig {
return headers; return headers;
} }
// ---------------------------------------------------------------------------
// WHAT THE TOKEN ALREADY SAYS ABOUT THE RIDER
// ---------------------------------------------------------------------------
/// The `tenantid` claim carried in the bearer token, or 0 when there is none.
///
/// ── Why the app reads its own token ──
///
/// The rider's tenant decides his entire operational mode, and the server has
/// always known it — `GenerateToken` signs `tenantid` into every miler JWT.
/// What it did *not* do was put it in the `verify-pin` response body, so the
/// app was told the answer and could not hear it: the login carried tenant 13
/// in its token and a body with no tenant at all, and every rider resolved to
/// the fallback line.
///
/// The handler now returns it too, but a deployed backend is not the same
/// thing as a merged one, and the app should not need a release to be
/// redeployed alongside. The claim is the same fact from the same source —
/// signed by the server, not asserted by the client — so reading it closes
/// the gap without waiting on anything.
///
/// ── What this is not ──
///
/// It is **not** a security decision and must never become one. The signature
/// is not verified here — the app has no key and does not need one, because
/// every request is still authorised server-side by the same token. A rider
/// who edited this claim would change which screens his own phone draws and
/// nothing else; the API would keep answering for the tenant it verified.
///
/// Returns 0 for a missing, malformed or unparseable token rather than
/// throwing: an unreadable token must fall through to the other signals, not
/// take the app down at launch.
static int tenantIdFromToken(String? token) {
if (token == null || token.isEmpty) return 0;
try {
final parts = token.split('.');
if (parts.length != 3) return 0;
// JWT uses base64url without padding; `base64Url.decode` demands it.
String payload = parts[1];
payload += '=' * ((4 - payload.length % 4) % 4);
final decoded = json.decode(utf8.decode(base64Url.decode(payload)));
if (decoded is! Map) return 0;
final raw = decoded['tenantid'];
if (raw is int) return raw;
if (raw is num) return raw.toInt();
return int.tryParse(raw?.toString() ?? '') ?? 0;
} catch (e) {
debugPrint('[AUTH] could not read tenant from token: $e');
return 0;
}
}
/// The stored session's tenant claim. See [tenantIdFromToken].
static Future<int> storedTenantId() async =>
tenantIdFromToken(await getToken());
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Response envelope adapter: {success,data,message} -> {status,details} // Response envelope adapter: {success,data,message} -> {status,details}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -94,26 +156,95 @@ class ApiConfig {
// / picked / skipped / cancelled / rejected // / picked / skipped / cancelled / rejected
static String legacyStatusFromNew(String? newStatus) { static String legacyStatusFromNew(String? newStatus) {
switch ((newStatus ?? '').trim()) { // ── Parsed, not string-matched ──
case 'Miler_Assigned': //
// Admin-assigned but NOT yet accepted by the rider. This must land on // This was a `switch` on raw strings whose `default` returned the value
// the Home tab as a pending booking to accept/reject — it becomes // unchanged, so two things went wrong quietly. `Pending_Pickup` and
// 'accepted' (a client-side marker in accepted_store) only once the // `Created` were not listed at all and fell through as themselves, and a
// rider accepts it, which is what moves it to the Bookings tab. // status added by the backend tomorrow would do the same — arriving in the
return 'assigned'; // UI as an unrecognised string that the row logic then had to guess at.
case 'Pickup_Scheduled': //
return 'active'; // [BookingStatus] covers the contract exhaustively and folds everything
case 'At_Customer': // else into `unknown`, which maps to the empty string here: a stop the app
return 'arrived'; // cannot classify renders as undecided, never as picked, cancelled or
case 'Picked_Up': // otherwise finished. Wrong-but-safe beats wrong-and-settled.
return 'Picked up'; return switch (BookingStatus.parse(newStatus)) {
case 'Converted_To_Consignment': // Admin-assigned but NOT yet accepted by the rider. These land on Home as
return 'picked'; // pending bookings to accept or reject; the client-side accepted marker
case 'Cancelled': // is what moves one to the work tab.
return 'cancelled'; BookingStatus.pendingPickup ||
default: BookingStatus.created ||
return newStatus ?? ''; BookingStatus.milerAssigned => 'assigned',
} // ── `Pickup_Scheduled` is the ACCEPTED rung, not the arrived one ──
//
// It is the status the backend writes when the rider accepts an
// assignment — the flow doc calls it "the pickup is on their route", and
// the console maps it to *accepted* for exactly that reason.
//
// This mapped it to `active`, which every row in this app reads as *the
// rider is physically on the stop* (see `stopStateOf`, where a raw
// `active` outranks the local accepted record). The effect was that
// accepting skipped a whole rung: the moment the queue came back, the
// stop reported as arrived, and selecting it offered **Mark as Picked**
// for a kitchen the rider had not reached yet. Arrival — the one rung
// that has a real endpoint behind it, `reached` — could not be recorded
// at all, so the hub never saw it.
//
// The two applications were reading one status two different ways. This
// is the app's half of that; the console's half already said accepted.
BookingStatus.pickupScheduled => 'accepted',
// The rung `reached` writes. It was only ever reachable through the
// undocumented `At_Customer` spelling, handled here as a special case
// ahead of the parse; `Arrived_At_Pickup` is the contract name and both
// now come through [BookingStatus].
BookingStatus.arrivedAtPickup => 'arrived',
BookingStatus.pickedUp => 'Picked up',
BookingStatus.convertedToConsignment => 'picked',
// ── Past the boundary, and it has to say so ──
//
// A hyperlocal booking is released for delivery by `pickup-complete`
// itself, so this is the status most collected DailyGrubs orders carry.
// It fell through as `unknown` → '' → *undecided*, which put a bag
// already in the rider's box back on Home as work to accept. See
// [BookingStatus.outForDelivery].
BookingStatus.outForDelivery => 'outfordelivery',
BookingStatus.delivered => 'delivered',
BookingStatus.cancelled => 'cancelled',
BookingStatus.unknown => '',
};
}
/// The delivery half of the same translation.
///
/// ── Why a second mapper exists ──
///
/// A booking's story ends at `Converted_To_Consignment`. From there the work
/// belongs to a different object with a different vocabulary, and the app
/// had no way to see it on a list: every collected stop — in the box, on the
/// road, handed over an hour ago — reported the same terminal booking word.
/// That is why a delivered stop kept sitting on the Deliveries tab until
/// something asked its consignment directly, one round trip per stop.
///
/// Since 21 Aug 2026 `GET /miler/bookings` carries `consignmentstatus` on
/// every row, so the list itself answers it.
///
/// Returns `''` for the hub-side states (`Created`, `Inwarded_at_Hub`,
/// `Tripsheet_Loaded`, `In_Transit`) and for anything unrecognised — the
/// caller then keeps the booking's own word. A hub-side consignment is not
/// this rider's to act on and has no rung on his card; inventing one would
/// put a parcel in somebody else's warehouse on his screen.
static String legacyStatusFromConsignment(Object? raw) {
return switch (consignmentStateFromRaw(raw)) {
// Collected and in the rider's hands. Same rung the booking's
// `Converted_To_Consignment` produces — but now it is the consignment
// itself saying so.
ConsignmentState.collectedByMiler => 'picked',
ConsignmentState.outForDelivery => 'outfordelivery',
ConsignmentState.delivered => 'delivered',
ConsignmentState.cancelled ||
ConsignmentState.returnedToSender => 'cancelled',
_ => '',
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -124,6 +255,9 @@ class ApiConfig {
// pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer, // pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer,
// pickupcontactno, collectionamt, step, type ...). Translate a new `booking` // pickupcontactno, collectionamt, step, type ...). Translate a new `booking`
// object into that shape so the existing cards/flows render unchanged. // object into that shape so the existing cards/flows render unchanged.
/// The sequence spellings this adapter looks for, in [RouteOrder]'s order.
static const List<String> sequenceFieldNames = RouteOrder.sequenceKeys;
static Map<String, dynamic> pickupFromBooking(Map booking) { static Map<String, dynamic> pickupFromBooking(Map booking) {
String s(dynamic v) => v == null ? '' : v.toString(); String s(dynamic v) => v == null ? '' : v.toString();
// First non-null value among several candidate keys — makes the adapter // First non-null value among several candidate keys — makes the adapter
@@ -161,6 +295,44 @@ class ApiConfig {
'referenceno', 'referenceno',
'orderid', 'orderid',
]); ]);
// ── One customer pickup can be several drops ──
//
// A customer-app booking carries N destinations and `GET /miler/bookings`
// returns ONE ROW PER DESTINATION once the pickup has been collected —
// same `bookingid`, same `bookingreference`, different door. The backend
// labels them for us: `destinationseq` is which one this is and
// `destinationcount` how many there are (both absent, or 1, on every
// console and milk-run booking, which is the whole existing world).
//
// Everything the rider's device remembers about a stop is keyed on
// `orderid` — the accepted store dedupes on it, the consignment-id map
// files under it, the collected and out-for-delivery sets hold it, the ETA
// and km keys are built from it. So three drops sharing one `orderid` is
// not a display bug: `addAcceptedBookings` deduped two of the three away
// before any screen saw them, and the two consignment ids that lost the
// race were unrecoverable, which is a bag the rider is holding with no
// door to take it to.
//
// So `orderid` becomes the **stop** key and carries the destination on it.
// The booking's own reference is untouched below in `bookingreference`,
// which is what every screen shows the rider and what he reads out on the
// phone. Single-destination bookings are byte-identical to before — the
// suffix only exists where there is something to tell apart.
final destinationCount =
int.tryParse(
(pick(['destinationcount', 'destinationCount']) ?? '').toString(),
) ??
1;
final destinationSeq =
int.tryParse(
(pick(['destinationseq', 'destinationSeq']) ?? '').toString(),
) ??
0;
final bookingKey = ref ?? id;
final stopKey = destinationCount > 1
? '$bookingKey#$destinationSeq'
: bookingKey;
final status = pick([ final status = pick([
'status', 'status',
'bookingstatus', 'bookingstatus',
@@ -168,16 +340,62 @@ class ApiConfig {
'orderstatus', 'orderstatus',
'state', 'state',
]); ]);
// ── The consignment outranks the booking, when there is one ──
//
// Not a preference — a correction. `Converted_To_Consignment` is where the
// booking stops being informative, so if the row also reports where the
// consignment has got to, that is the newer fact and the one the card must
// draw. Falls back to the booking's word whenever the consignment says
// nothing this rider can act on. See [legacyStatusFromConsignment].
final consignmentStatus = pick([
'consignmentstatus',
'consignmentStatus',
'consignment_status',
]);
final fromConsignment = legacyStatusFromConsignment(consignmentStatus);
return <String, dynamic>{ return <String, dynamic>{
// identity // identity
'pickupid': id, 'pickupid': id,
'orderid': ref ?? id, // The STOP key — see the note above. `bookingreference` is the booking's
// own name and is what gets shown; this is what gets remembered.
'orderid': stopKey,
'orderheaderid': id, 'orderheaderid': id,
'bookingid': id, // keep original too 'bookingid': id, // keep original too
'bookingreference': s(ref), 'bookingreference': s(ref),
// ── Which drop of the visit this is ──
//
// Carried so a card can say "Stop 2 of 3" rather than showing three rows
// that are identical down to the reference number. `destinationcount` is
// 1 for every single-destination and console booking, and the UI treats
// 1 as "say nothing".
'destinationseq': destinationSeq,
'destinationcount': destinationCount,
// ── The parcel's own number, and who is waiting for it ──
//
// All three exist per DESTINATION, not per booking: on a three-drop
// pickup each door has its own tracking number and its own receiver, and
// the booking-level customer is the SENDER, who is not at any of them.
// Dropped by this adapter until now, so the rider arrived at a stranger's
// door with the sender's name on his screen and no number to read out.
'trackingno': s(pick(['trackingno', 'trackingNo', 'tracking_no'])),
'recipientname': s(
pick(['recipientname', 'recipientName', 'recipient_name']),
),
'recipientphone': s(
pick(['recipientphone', 'recipientPhone', 'recipient_phone']),
),
// status // status
'orderstatus': legacyStatusFromNew(s(status)), 'orderstatus': fromConsignment.isNotEmpty
? fromConsignment
: legacyStatusFromNew(s(status)),
// Kept raw alongside, so anything that needs the consignment's own word
// reads it rather than inferring it back out of the legacy one.
'consignmentstatus': s(consignmentStatus),
// pickup side // pickup side
'pickupcustomer': s( 'pickupcustomer': s(
@@ -201,6 +419,9 @@ class ApiConfig {
'pickupaddress': s( 'pickupaddress': s(
pick(['pickupaddress', 'pickupAddress', 'pickup_address']), pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
), ),
'pickuppincode': s(
pick(['pickuppincode', 'pickupPincode', 'pickup_pincode']),
),
'pickuplat': s( 'pickuplat': s(
pick([ pick([
'pickuplatitude', 'pickuplatitude',
@@ -232,6 +453,9 @@ class ApiConfig {
'dropaddress': s( 'dropaddress': s(
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']), pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
), ),
'droppincode': s(
pick(['deliverypincode', 'deliveryPincode', 'delivery_pincode']),
),
'droplat': s( 'droplat': s(
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']), pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
), ),
@@ -239,9 +463,175 @@ class ApiConfig {
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']), pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
), ),
// stop type — new bookings are first-mile PICKUPS; delivery legs come // ── Where this stop is collected FROM ──
// through the consignment flow. Backend has no per-stop `type` yet. //
'type': 'pickup', // On a milk run the rider works two or three sources in a morning and
// Home groups his stops under one heading per source, with the bulk
// collect belonging to that group. `stopSourceId` / `stopSourceName` read
// exactly these keys — and this adapter builds a fixed map, so a field it
// does not name is a field the UI can never see, however faithfully the
// backend sends it. That was the bug: every milk-run stop grouped into
// one nameless pile.
//
// Empty on a logistics booking, which is collected from a customer's door
// rather than from a source. Nothing is invented when the keys are
// absent: an empty string groups as "no source", which is the truth.
'sourceid': s(
pick([
'sourceid',
'sourceId',
'source_id',
'kitchenid',
'kitchenId',
'pickuplocationid',
'pickupLocationId',
]),
),
'sourcename': s(
pick([
'sourcename',
'sourceName',
'source_name',
'kitchenname',
'kitchenName',
'providerlocation',
'providercompany',
]),
),
'pickuplocationid': s(
pick(['pickuplocationid', 'pickupLocationId', 'pickup_location_id']),
),
// Present once the booking has been converted. It is what the delivery
// route keys on, so a milk-run drop cannot be closed without it.
'consignmentid': s(
pick(['consignmentid', 'consignmentId', 'consignment_id']),
),
// ── Where this parcel goes next, in the server's own word ──
//
// Shipped with request 27. Until it existed, `next_action` was returned
// **once** — by `pickup-complete` — and the app had to keep the pivot's
// answer on the handset to survive a poll, because `Created` is both the
// state a hub-routed parcel settles on *and* the state one holds while
// the pivot is still routing it. That cache is now the fallback rather
// than the source: [NextLegResolver] prefers this field over it, so a
// reinstall, a second device or a routing the office changed mid-day all
// read the live answer.
//
// Values: `pickup`, `start_delivery`, `inward_at_hub`, `deliver`,
// `handed_to_hub`, `none`. Carried verbatim — the resolver owns the
// reading of them, and an unrecognised word must reach it intact so it
// can fall through rather than being flattened here.
'next_action': s(
pick(['next_action', 'nextaction', 'nextAction']),
),
// ── The base this parcel is to be handed in at ──
//
// A nested object, carried whole: id, name, address, pincode, latitude,
// longitude. Null on a hyperlocal parcel, which has no base leg. See
// [HandoverHub], which is the only thing that reads it.
//
// This is the field that makes a handover navigable. Before it the app
// knew a parcel was hub-routed and had no idea which building — every
// `hubLat`/`hubLng` in the codebase was the rider's own position standing
// in for one.
'next_hub': booking['next_hub'] ?? booking['nexthub'],
// ── What kind of place this is collected FROM ──
//
// Shipped with request 28. `hub` / `customer` / `merchant` / `store`, on
// the row rather than against a location master — a customer-door pickup
// has no location id at all, so a type held against locations could never
// classify one.
//
// Home titled every logistics pickup group with the rider's own base name
// before this, because the per-booking source was unreliable and a
// constant was the safer wrong answer. This is what retires that.
'pickup_source_type': s(
pick(['pickup_source_type', 'pickupsourcetype', 'pickupSourceType']),
),
// The counter's own name, as the hub holds it. Distinct from
// `sourcename`, which the backend fills from `providercompany` /
// `providerlocation` and which is a contact person on some rows — that is
// how the biggest type on Home once read `Sudharsan`.
'pickup_source_name': s(
pick(['pickup_source_name', 'pickupsourcename', 'pickupSourceName']),
),
// ── The hub's solved position in the route ──
//
// Verified live 21 Aug 2026: `GET /miler/bookings` carries `step` on
// every row. This adapter builds a **fixed map**, so a field it does not
// name is a field the UI can never see however faithfully the backend
// sends it — and `step` was not named. The delivery leg therefore had no
// sequence at all and fell back to ordering by distance, which is the
// app re-planning a route the hub had already solved.
//
// `0` means *not sequenced* and is passed through as such; see
// [RouteOrder.sequenceOf], which treats it as "no answer", never as
// position zero.
'step': pick(sequenceFieldNames) ?? 0,
// ── The stamp that says whether `step` means anything ──
//
// Dropped here until now, which made the whole sequencing contract
// unreadable: [RouteOrder.isSequenced] looks for this key and the adapter
// never wrote it, so every adapted row looked unsequenced and the app
// fell back to nearest-first on routes the hub had actually solved.
//
// The backend's rule, confirmed 25 Aug: **`sequencedat` is the
// authority, not `step`.** Non-null → a route was assigned, follow `step`
// exactly. Null → no route, and the fallback is correct. `step: 0` with a
// null stamp is not a bug: it is a rider holding fewer than two active
// stops, or a stop without coordinates — neither of which is a route.
'sequencedat': pick(RouteOrder.sequencedAtKeys),
// ── The arrival, as the backend records it ──
//
// Confirmed by the backend team and shipped with their redeploy:
// `/reached` writes an arrival **event**, and `GET /miler/bookings`
// returns it on the row. There is no `Arrived_At_Pickup` booking status
// and there never was — the status stays `Pickup_Scheduled` and the
// stamp beside it is what says he is there.
//
// Carried through here so the rung can be rebuilt from server data alone
// after a refresh or a restart, which is the thing the local arrival
// record exists to stand in for. See [riderStageOf].
'reachedat': pick(const [
'reachedat',
'reachedAt',
'reached_at',
'arrivedat',
]),
'arrivallatitude': pick(const [
'arrivallatitude',
'arrivalLatitude',
'arrival_latitude',
]),
'arrivallongitude': pick(const [
'arrivallongitude',
'arrivalLongitude',
'arrival_longitude',
]),
// ── Which leg this stop is ──
//
// Also live, also previously hardcoded: every row came through as
// `'pickup'` because "backend has no per-stop type yet". It does now —
// 23 of this rider's 29 rows say `delivery`.
'type': s(pick(['stoptype', 'stopType', 'stop_type'])).isEmpty
? 'pickup'
: s(pick(['stoptype', 'stopType', 'stop_type'])).toLowerCase(),
// Route estimates, straight from the assignment. Zero until the hub's
// optimizer has run — the app shows its own estimate in that case and
// says so rather than drawing a confident 0.
'etaminutes': pick(['etaminutes', 'etaMinutes']) ?? 0,
'cumulativekms': pick(['cumulativekms', 'cumulativeKms']) ?? 0,
'cumulativeeta': pick(['cumulativeeta', 'cumulativeEta']) ?? 0,
// money — NOT provided by the new booking object yet (see gaps doc) // money — NOT provided by the new booking object yet (see gaps doc)
'collectionamt': booking['collectionamt'] ?? 0, 'collectionamt': booking['collectionamt'] ?? 0,
@@ -251,9 +641,92 @@ class ApiConfig {
'parcels': booking['parcels'] ?? const [], 'parcels': booking['parcels'] ?? const [],
'starttime': s(booking['createdat']), 'starttime': s(booking['createdat']),
'eta': s(booking['eta']), 'eta': s(booking['eta']),
// ── The row's own clocks, carried verbatim ──
//
// This adapter builds a **fixed map**, so a field it does not name is a
// field the app can never see — the same trap `step` and `stoptype` were
// in. Every timestamp on the booking was in that trap, and the cost was
// not cosmetic: [ServiceDay] dates a row by exactly these key names, so
// an adapted row carried nothing to date it by and *every* screen that
// asks "does this belong to today" had to answer "cannot tell".
//
// That is how yesterday's assignments stayed on Home. The filter was
// never missing so much as starved: it was asking a question of fields
// this method had already thrown away.
//
// Copied under the names the wire uses, with no reshaping and no
// parsing. [ServiceDay] and [parseStamp] own the reading of them —
// Doormile sends IST wall-clock in naive columns, sometimes with a
// trailing `Z` it never had, and the one place that knows that should
// stay the one place that knows it.
for (final k in timestampFieldNames)
if (booking[k] != null && booking[k].toString().trim().isNotEmpty)
k: booking[k],
// ── And the row's distances, for the same reason ──
//
// `cumulativekms` was named above and the rest were not, so the whole
// distance vocabulary was dropped here: `kms` (what the hub planned),
// `riderkms` (what the rider actually covered) and the `compliance` block
// the contract carries them in.
//
// [StopCompliance] reads exactly those names, so it answered `null` for
// every adapted row — and Activity's `km ridden` totalled zero all day
// and printed an em dash. Same failure as the timestamps, on a different
// set of fields: a fixed map cannot pass on what it does not name.
for (final k in distanceFieldNames)
if (booking[k] != null && booking[k].toString().trim().isNotEmpty)
k: booking[k],
}; };
} }
/// Every distance key a booking row is known to carry, copied through
/// [pickupFromBooking] untouched.
///
/// Kept in step with [StopCompliance], which is what reads them. `compliance`
/// is a nested object rather than a number and is passed on whole — this
/// adapter's job is to stop losing fields, not to reshape them.
static const List<String> distanceFieldNames = [
// What the hub planned for this stop.
'kms',
'km',
// What the rider actually covered. `riderkms` is the backend's name;
// `actualkms` is the name this app posts on its own pickup write.
'riderkms',
'actualkms',
// The contract's block, carrying both plus the on-time verdict.
'compliance',
];
/// Every timestamp key a booking row is known to carry, copied through
/// [pickupFromBooking] untouched.
///
/// Deliberately a superset of what any one endpoint sends: a name that is
/// absent costs one map lookup, and a name that is missing costs a screen
/// its ability to tell today from yesterday. Kept in step with
/// `ServiceDay.timeKeys`, which is what reads them.
static const List<String> timestampFieldNames = [
'createdat',
'createdon',
'updatedat',
'updatedon',
'modifiedon',
'pickedtime',
'picked_time',
'deliverytime',
'deliveredat',
'completedat',
'expected_pickup_time',
'expectedpickuptime',
'slotstarttime',
'slotendtime',
'slotfrom',
'slotto',
'assignedat',
'assignedon',
];
static List<Map<String, dynamic>> pickupsFromBookings(dynamic data) { static List<Map<String, dynamic>> pickupsFromBookings(dynamic data) {
if (data is List) { if (data is List) {
return data.whereType<Map>().map((b) => pickupFromBooking(b)).toList(); return data.whereType<Map>().map((b) => pickupFromBooking(b)).toList();

270
lib/data/api_status.dart Normal file
View File

@@ -0,0 +1,270 @@
/// ─────────────────────────────────────────────────────────────────────────
/// THE CONTRACT'S OWN VOCABULARY
///
/// Assignment, booking and consignment statuses arrive as free-form strings and
/// were compared as string literals wherever a screen needed one. That is the
/// same mistake [StopStatus] was written to fix on the read side of the parcel
/// flow, one layer further out: case-sensitivity landmines, variant spellings,
/// and — the expensive one — **an unrecognised value silently taking the
/// success branch** because the check was `!= 'Cancelled'`.
///
/// ── Unknown is a value, not a crash and not a success ──
///
/// The backend will add statuses this build has never heard of. Every enum here
/// therefore carries an [unknown] member and parses by *exact match on a
/// normalised string*, so a new server value lands on `unknown` and the screens
/// treat it as "not something I can act on" rather than as delivered, accepted
/// or complete. `values.byName`-style lookups and `firstWhere` without an
/// `orElse` both throw; neither is used.
///
/// The raw string is kept alongside, because a status this build cannot model
/// is still something the rider and the hub can read.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// `Assigned_To_Miler` / `assigned to miler` / `ASSIGNED-TO-MILER` all reduce
/// to one key, so a spelling drift on the wire is not a behaviour change here.
String _key(Object? raw) => (raw?.toString() ?? '')
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[\s\-]+'), '_');
/// What the hub has done with an offer of work.
enum AssignmentStatus {
assigned,
accepted,
rejected,
reassigned,
completed,
cancelled,
unknown;
static const _byKey = <String, AssignmentStatus>{
'assigned': AssignmentStatus.assigned,
'accepted': AssignmentStatus.accepted,
'rejected': AssignmentStatus.rejected,
'reassigned': AssignmentStatus.reassigned,
'completed': AssignmentStatus.completed,
'cancelled': AssignmentStatus.cancelled,
};
static AssignmentStatus parse(Object? raw) =>
_byKey[_key(raw)] ?? AssignmentStatus.unknown;
/// The rider still owes this one a decision.
bool get needsDecision => this == AssignmentStatus.assigned;
/// He has taken it on and it is not finished.
bool get isLive => this == AssignmentStatus.accepted;
/// Nothing further will happen here. **`unknown` is deliberately not
/// settled** — a status this build cannot read must not be filed as done.
bool get isSettled =>
this == AssignmentStatus.rejected ||
this == AssignmentStatus.reassigned ||
this == AssignmentStatus.completed ||
this == AssignmentStatus.cancelled;
}
/// A pickup, from the moment it exists to the moment it becomes a consignment.
enum BookingStatus {
pendingPickup,
created,
milerAssigned,
pickupScheduled,
/// The rider is standing at the pickup address.
///
/// `POST /miler/bookings/:id/reached` writes this — but until 21 Aug 2026 it
/// wrote nothing the app could observe, so **I've arrived** appeared to do
/// nothing and the console never showed the rung. The backend now persists
/// it under this name. `At_Customer` is the older spelling seen in the wild
/// and means the same thing; both parse here.
arrivedAtPickup,
pickedUp,
convertedToConsignment,
/// ── The hyperlocal short-circuit ──
///
/// `pickup-complete` decides routing from the two pincodes: matching 3-digit
/// prefixes are hyperlocal and the parcel goes **straight to
/// `Out_for_Delivery`** instead of routing via a hub. Every DailyGrubs run is
/// hyperlocal, so this is not an edge case on that line — it is the status a
/// collected meal actually carries.
///
/// It was missing from this enum, and the cost was precise: [parse] answered
/// [unknown], which the legacy translation maps to the empty string, which
/// reads as *undecided* — so a bag already in the rider's box came back from
/// the queue looking like work he had not accepted yet. The local collected
/// record hid it on the device that did the pickup and nowhere else: a
/// restart, a reinstall or a second device showed collected orders sitting on
/// Home as pending.
outForDelivery,
/// Handed over. Terminal.
delivered,
cancelled,
unknown;
static const _byKey = <String, BookingStatus>{
'pending_pickup': BookingStatus.pendingPickup,
'created': BookingStatus.created,
'miler_assigned': BookingStatus.milerAssigned,
'pickup_scheduled': BookingStatus.pickupScheduled,
'arrived_at_pickup': BookingStatus.arrivedAtPickup,
'at_customer': BookingStatus.arrivedAtPickup,
'picked_up': BookingStatus.pickedUp,
'converted_to_consignment': BookingStatus.convertedToConsignment,
// Spelled `Out_for_Delivery` on bookings — lower-case `f`, unlike the
// consignment enum's `Out_For_Delivery`. `_key` lower-cases before lookup
// so both land here, which is deliberate: the difference is a backend
// inconsistency, not a distinction, and no caller should have to know it.
'out_for_delivery': BookingStatus.outForDelivery,
'delivered': BookingStatus.delivered,
'cancelled': BookingStatus.cancelled,
};
static BookingStatus parse(Object? raw) =>
_byKey[_key(raw)] ?? BookingStatus.unknown;
/// Collection has happened — the pickup-to-delivery boundary has been
/// crossed, server-side.
///
/// `pickup-complete` is the pivot: it converts the booking into a consignment
/// and, on a hyperlocal run, releases it for delivery in the same call. Every
/// rung from there on counts, including [delivered] — a delivered order was
/// certainly collected, and a predicate that said otherwise would put a
/// finished stop back in the pickup domain.
///
/// This is what [WorkBoundary] reads. It must never include a rung before the
/// hand-over: an acceptance is a decision about work still to be done.
bool get isCollected =>
this == BookingStatus.pickedUp ||
this == BookingStatus.convertedToConsignment ||
this == BookingStatus.outForDelivery ||
this == BookingStatus.delivered;
/// The rider still has work to do at this address.
bool get isOpen =>
this == BookingStatus.pendingPickup ||
this == BookingStatus.created ||
this == BookingStatus.milerAssigned ||
this == BookingStatus.pickupScheduled ||
this == BookingStatus.arrivedAtPickup;
/// ── Cancellation is refused once picked up ──
///
/// The server enforces it; this is the client half, so the control is not
/// offered in a state where pressing it can only fail.
bool get canCancel => isOpen;
}
/// A consignment, from the hub's point of view.
enum ConsignmentStatus {
created,
inwardedAtHub,
tripsheetLoaded,
inTransit,
outForDelivery,
delivered,
rtoInitiated,
returnedToSender,
missing,
damaged,
unknown;
static const _byKey = <String, ConsignmentStatus>{
'created': ConsignmentStatus.created,
'inwarded_at_hub': ConsignmentStatus.inwardedAtHub,
'tripsheet_loaded': ConsignmentStatus.tripsheetLoaded,
'in_transit': ConsignmentStatus.inTransit,
'out_for_delivery': ConsignmentStatus.outForDelivery,
'delivered': ConsignmentStatus.delivered,
'rto_initiated': ConsignmentStatus.rtoInitiated,
'returned_to_sender': ConsignmentStatus.returnedToSender,
'missing': ConsignmentStatus.missing,
'damaged': ConsignmentStatus.damaged,
};
static ConsignmentStatus parse(Object? raw) =>
_byKey[_key(raw)] ?? ConsignmentStatus.unknown;
/// The one state `deliver` and `skip` are legal from — anything else is a
/// 400. Offering the control elsewhere is offering a guaranteed failure.
bool get isDeliverable => this == ConsignmentStatus.outForDelivery;
/// Handed over. Only this one.
bool get isDelivered => this == ConsignmentStatus.delivered;
/// Going back, or gone. Not failures the rider caused, and not states he can
/// work out of on this screen.
bool get isReturning =>
this == ConsignmentStatus.rtoInitiated ||
this == ConsignmentStatus.returnedToSender;
/// Something is wrong with the parcel itself and the hub owns it now.
bool get isException =>
this == ConsignmentStatus.missing || this == ConsignmentStatus.damaged;
/// Nothing further happens on the rider's phone. **`unknown` is excluded** —
/// see the note at the top of this file.
bool get isClosed => isDelivered || isReturning || isException;
}
/// What the rider is doing, as the availability endpoint understands it.
///
/// `Break`, not `On_Break`: the obvious guess is the wrong one, and it is the
/// value the server validates against.
enum RiderAvailability {
offline,
available,
assigned,
onPickup,
atCustomer,
pickedUp,
onDelivery,
onBreak,
blocked,
unknown;
static const _byKey = <String, RiderAvailability>{
'offline': RiderAvailability.offline,
'available': RiderAvailability.available,
'assigned': RiderAvailability.assigned,
'on_pickup': RiderAvailability.onPickup,
'at_customer': RiderAvailability.atCustomer,
'picked_up': RiderAvailability.pickedUp,
'on_delivery': RiderAvailability.onDelivery,
'break': RiderAvailability.onBreak,
'blocked': RiderAvailability.blocked,
};
static RiderAvailability parse(Object? raw) =>
_byKey[_key(raw)] ?? RiderAvailability.unknown;
/// The exact string this value is sent back as. Named separately from the
/// Dart member so `onBreak` can carry the wire's `Break` without the enum
/// having a member called `break`, which is a keyword.
String get wire => switch (this) {
RiderAvailability.offline => 'Offline',
RiderAvailability.available => 'Available',
RiderAvailability.assigned => 'Assigned',
RiderAvailability.onPickup => 'On_Pickup',
RiderAvailability.atCustomer => 'At_Customer',
RiderAvailability.pickedUp => 'Picked_Up',
RiderAvailability.onDelivery => 'On_Delivery',
RiderAvailability.onBreak => 'Break',
RiderAvailability.blocked => 'Blocked',
// Never sent. A value this build cannot model must not be echoed back to
// the server as though it were understood.
RiderAvailability.unknown => 'Offline',
};
/// On duty in any sense — anything but offline, blocked, or unreadable.
bool get isWorking =>
this != RiderAvailability.offline &&
this != RiderAvailability.blocked &&
this != RiderAvailability.unknown;
}

View File

@@ -1,8 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:miler/data/api_config.dart'; import 'package:miler/data/miler_api.dart';
/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject /// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject
/// endpoints key on. /// endpoints key on.
@@ -21,6 +19,27 @@ class AssignmentLookup {
/// bookingid (as string) -> bookingassignmentid /// bookingid (as string) -> bookingassignmentid
static final Map<String, int> _cache = <String, int>{}; static final Map<String, int> _cache = <String, int>{};
/// bookingid (as string) -> the hub's solved stop number.
///
/// ── Why the sequence lives here and not on the booking ──
///
/// `step` is written onto **`bookingassignments`**, not onto the booking: the
/// hub's batch-assign endpoint sends each affected rider's whole active set to
/// the route optimizer and writes the returned road-network order back onto
/// the assignment rows. So `GET /miler/bookings` — the app's one read of the
/// day — cannot carry it, and this endpoint is the only place it exists.
///
/// It was being thrown away. This class fetched the assignment rows for their
/// ids and dropped everything else, so `Trip.sortStops` — which documents
/// `step` as authoritative and says the app must never second-guess the
/// admin's order — never saw one and fell back to booked time on every run.
/// The rider was choosing his own order while the hub believed it had solved
/// one for him.
///
/// `step: 0` means *not sequenced*, never *first*, and is not stored.
static final Map<String, int> _steps = <String, int>{};
static DateTime? _fetchedAt; static DateTime? _fetchedAt;
/// Assignments change whenever the hub assigns work, so the map goes stale /// Assignments change whenever the hub assigns work, so the map goes stale
@@ -41,9 +60,22 @@ class AssignmentLookup {
static void invalidate() { static void invalidate() {
_cache.clear(); _cache.clear();
_steps.clear();
_fetchedAt = null; _fetchedAt = null;
} }
/// The hub's stop order, by booking id, refreshed on the same TTL as the ids.
///
/// Best-effort by contract: sequencing is a separate service and the flow doc
/// is explicit that it being down must leave bookings *assigned but
/// unordered* rather than undo anything. An empty map therefore means "no
/// solved order available", which the sort reads as "fall back to booked
/// time" — not as "every stop is step 0".
static Future<Map<String, int>> steps() async {
if (_isStale) await _refresh();
return Map<String, int>.unmodifiable(_steps);
}
/// The assignment id for [bookingId], or null when the backend has no /// The assignment id for [bookingId], or null when the backend has no
/// assignment row for it (or the call fails). /// assignment row for it (or the call fails).
/// ///
@@ -67,22 +99,21 @@ class AssignmentLookup {
static Future<void> _refresh() async { static Future<void> _refresh() async {
try { try {
final uri = Uri.parse(ApiConfig.url('/miler/assignments')); // Through [MilerApi], not a hand-rolled `http.get`. It was the latter,
final res = await http // which meant this one call carried its own header building, its own
.get(uri, headers: await ApiConfig.authHeaders()) // envelope unwrapping and its own idea of what a 2xx is — and, because it
.timeout(const Duration(seconds: 15)); // bypassed `MilerApi.client`, it was the only request in the app that a
if (res.statusCode < 200 || res.statusCode >= 300) { // test could not stub, so the suite made real network calls to the
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.statusCode}'); // production API while checking a repository.
final res = await MilerApi.assignments();
if (!res.ok) {
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.status}');
return; return;
} }
final decoded = json.decode(res.body); final List<dynamic> data = res.list;
dynamic data = decoded; if (data.isEmpty && res.data is! List) {
if (decoded is Map) { debugPrint('[ASSIGNMENTS] no assignment rows in the response');
data = decoded['data'] ?? decoded['details'] ?? decoded['assignments'];
}
if (data is! List) {
debugPrint('[ASSIGNMENTS] unexpected payload: ${data.runtimeType}');
return; return;
} }
@@ -90,9 +121,14 @@ class AssignmentLookup {
_cache _cache
..clear() ..clear()
..addAll(next); ..addAll(next);
final nextSteps = buildStepIndex(data);
_steps
..clear()
..addAll(nextSteps);
_fetchedAt = DateTime.now(); _fetchedAt = DateTime.now();
debugPrint( debugPrint(
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids', '[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids, '
'${_steps.length} sequenced',
); );
} catch (e) { } catch (e) {
debugPrint('[ASSIGNMENTS] fetch error: $e'); debugPrint('[ASSIGNMENTS] fetch error: $e');
@@ -131,6 +167,44 @@ class AssignmentLookup {
return index; return index;
} }
/// Collapses the assignment rows into one bookingid -> step entry.
///
/// Same newest-first, actionable-wins rule as [buildIndex] — a booking that
/// was assigned, rejected and reassigned must take the live assignment's
/// sequence, not the rejected corpse's — so the two indexes cannot describe
/// different assignment rows for the same booking.
@visibleForTesting
static Map<String, int> buildStepIndex(List<dynamic> rows) {
final index = <String, int>{};
final tookActionable = <String>{};
for (final row in rows.whereType<Map>()) {
final bookingKey = row['bookingid']?.toString().trim() ?? '';
if (bookingKey.isEmpty) continue;
final step = _asInt(row['step']) ?? 0;
final status = (row['assignmentstatus'] ?? '').toString().trim();
final isActionable = _actionable.contains(status);
final seen =
index.containsKey(bookingKey) || tookActionable.contains(bookingKey);
if (seen && !(isActionable && !tookActionable.contains(bookingKey))) {
continue;
}
if (isActionable) tookActionable.add(bookingKey);
// 0 is "not sequenced". Storing it would make an unsequenced stop look
// like it had been solved into position zero.
if (step > 0) {
index[bookingKey] = step;
} else {
index.remove(bookingKey);
}
}
return index;
}
static int? _asInt(dynamic v) { static int? _asInt(dynamic v) {
if (v is int) return v; if (v is int) return v;
if (v is num) return v.toInt(); if (v is num) return v.toInt();

View File

@@ -0,0 +1,444 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE CONSIGNMENT'S OWN VOCABULARY
///
/// A booking and a consignment are two different objects with two different
/// state machines, and the app has been paying for treating them as one.
///
/// booking Pending_Pickup → Miler_Assigned → Pickup_Scheduled
/// → Picked_Up → **Converted_To_Consignment** | Cancelled
///
/// consignment Created → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit
/// → **Out_for_Delivery** → **Delivered** | RTO | …
///
/// `Converted_To_Consignment` is where the booking's story ENDS. There is no
/// booking `Delivered`, and `GET /miler/bookings` therefore reports the same
/// terminal word for a parcel sitting in a hub, a parcel on a rider's bike and
/// a parcel handed over an hour ago. Any code that decides "is this stop still
/// mine to deliver?" from a booking status is asking the wrong object — which
/// is exactly the defect this file exists to close.
///
/// ── What this is not ──
///
/// Not a local mirror and not a cache to write into. The consignment's state
/// belongs to the server; the only honest way to know it is to ask. Nothing
/// here ever *sets* a state — see [ConsignmentGate].
/// ─────────────────────────────────────────────────────────────────────────
enum ConsignmentState {
created,
inwardedAtHub,
tripsheetLoaded,
inTransit,
/// **In the rider's hands, not yet on the road.**
///
/// Added by the backend on 21 Aug 2026 and it closes the gap this app had
/// been modelling locally: `pickup-complete` used to push hyperlocal work
/// straight to [outForDelivery], so the hub saw "actively delivering" for
/// food still on the kitchen counter and there was no server state meaning
/// *collected, holding*. Now the pivot lands here and
/// `POST /consignments/:id/start-delivery` makes the release.
collectedByMiler,
/// Released to a rider. **The only state `deliver` accepts.**
outForDelivery,
/// Handed over. Terminal.
delivered,
rtoInitiated,
returnedToSender,
missing,
damaged,
cancelled,
/// The server said something this build does not know. Deliberately not
/// deliverable and deliberately not "finished" — an unrecognised state is a
/// reason to ask, never a reason to act.
unknown,
}
/// Normalises the backend's `eventstatus` / `status` spelling.
ConsignmentState consignmentStateFromRaw(dynamic raw) {
final s = (raw?.toString() ?? '').trim().toLowerCase().replaceAll(' ', '_');
switch (s) {
case 'created':
return ConsignmentState.created;
case 'inwarded_at_hub':
return ConsignmentState.inwardedAtHub;
case 'tripsheet_loaded':
return ConsignmentState.tripsheetLoaded;
case 'in_transit':
return ConsignmentState.inTransit;
case 'collected_by_miler':
case 'collectedbymiler':
return ConsignmentState.collectedByMiler;
case 'out_for_delivery':
case 'outfordelivery':
return ConsignmentState.outForDelivery;
case 'delivered':
return ConsignmentState.delivered;
case 'rto_initiated':
return ConsignmentState.rtoInitiated;
case 'returned_to_sender':
return ConsignmentState.returnedToSender;
case 'missing':
return ConsignmentState.missing;
case 'damaged':
return ConsignmentState.damaged;
case 'cancelled':
case 'canceled':
return ConsignmentState.cancelled;
default:
return ConsignmentState.unknown;
}
}
extension ConsignmentStateX on ConsignmentState {
/// `POST /miler/consignments/:id/deliver` is refused unless the consignment
/// is `Out_for_Delivery`. One state, no inference, no other spelling.
bool get isDeliverable => this == ConsignmentState.outForDelivery;
/// Already handed over. The work is done server-side; the rider's device is
/// the thing that is behind.
bool get isDelivered => this == ConsignmentState.delivered;
/// Closed for good, one way or another — nothing left for a rider to do.
bool get isClosed =>
this == ConsignmentState.delivered ||
this == ConsignmentState.cancelled ||
this == ConsignmentState.returnedToSender;
/// Collected and waiting on the rider's own **Start round**, not on anyone
/// else. Deliberately *not* [awaitsHub]: telling a rider the hub has his
/// parcel while it is in his own box is the error this state exists to
/// prevent.
bool get needsRelease => this == ConsignmentState.collectedByMiler;
/// Still inside the hub's half of the network. **This is the case the
/// "not released yet" guard is for** — a logistics consignment sitting at a
/// hub genuinely cannot be delivered by this rider, and must stay blocked.
///
/// ── `Created` is not one of these, and treating it as one stranded riders ──
///
/// It was listed here, and it is the wrong half of the network. The backend's
/// own contract for the pivot is:
///
/// ```
/// hyperlocal pickup-complete → Out_for_Delivery (compatibility mode)
/// → Collected_By_Miler (flag on)
/// hub-routed pickup-complete → Created + next_action: inward_at_hub
/// ```
///
/// So `Created` means *the consignment exists and nothing has happened to it
/// yet* — the parcel is *in the rider's own hands*, waiting either on his
/// release or on him carrying it to the hub. The hub has never seen it. The
/// rider slid **Start ride**, and the app answered "This parcel is with the
/// hub — it will be delivered from there, not by you" about a bag on his own
/// back, with no way forward from that screen.
///
/// Genuine hub custody starts at [ConsignmentState.inwardedAtHub] — the state
/// whose name says the hub took it in. See [awaitsHubInward] for `Created`.
bool get awaitsHub =>
this == ConsignmentState.inwardedAtHub ||
this == ConsignmentState.tripsheetLoaded ||
this == ConsignmentState.inTransit;
/// Converted, and not yet moved anywhere by anyone.
///
/// The parcel is with **this rider**. On a hyperlocal run this is a state the
/// release moves out of; on a hub-routed one his next act is to inward it at
/// the hub, which is not something this app does yet. Either way it is not a
/// reason to tell him the parcel is somebody else's — see [awaitsHub].
bool get awaitsHubInward => this == ConsignmentState.created;
/// After a successful `skip`, whether the stop is **still the rider's
/// problem**.
///
/// A skip is a failed attempt, not a closed consignment, and what the server
/// does with one is the server's business: it may move the consignment to a
/// failure state, or leave it `Out_for_Delivery` for a second attempt or an
/// RTO decision taken elsewhere. The app cannot tell from the skip's own
/// 200, so it reads the consignment afterwards and asks this.
///
/// **Unknown counts as open.** A read that failed is not permission to
/// declare a stop finished — writing a terminal local record over a
/// consignment the hub still calls open leaves two systems disagreeing about
/// whether a parcel is anyone's problem, with the rider's screen the only
/// one saying it is not.
bool get isOpenAfterSkip =>
isDeliverable || needsRelease || this == ConsignmentState.unknown;
/// `skip` is accepted from both halves of the rider's custody — the backend
/// widened it on 21 Aug 2026 so a failed attempt is reportable the moment
/// the parcel is collected, not only once the round has started.
bool get canSkip => needsRelease || isDeliverable;
/// Why **Start ride** could not set off, in words a rider can act on — or
/// null when this state is not a reason to refuse one.
///
/// ── The screen was reading the state machine out loud ──
///
/// When `start-delivery` refuses, the backend answers with an assertion
/// about its own model: *"consignment is Inwarded_At_Hub, not
/// Collected_By_Miler"*. That sentence was passed through to the rider
/// verbatim, on the reasoning that the server's own words beat a guess — and
/// that reasoning is right about *whose* answer it is and wrong about *what
/// kind of sentence* it is. `Inwarded_At_Hub` is a database enum. It is not
/// something a man holding a phone at a kitchen counter can do anything
/// with, and he had no way to tell it from a bug.
///
/// So the state is translated here, in the file that owns the vocabulary,
/// exactly once. Every sentence answers the only two questions he has: is
/// this mine, and is sliding again going to help.
///
/// Null for [outForDelivery], [collectedByMiler] and [unknown] — the first
/// two are not refusals at all and the third is not an answer, so the caller
/// falls back rather than inventing a cause.
String? get startRefusalSentence => switch (this) {
// Converted and not yet routed. The one refusal here that a second slide
// in a minute genuinely can clear.
ConsignmentState.created =>
'Your office is still setting this one up. Give it a moment and slide '
'again.',
// The logistics network has it. Said without naming a building the rider
// has never been to — what matters is that it is not his round.
ConsignmentState.inwardedAtHub ||
ConsignmentState.tripsheetLoaded ||
ConsignmentState.inTransit =>
"This one has already been handed on — it's not yours to deliver.",
ConsignmentState.delivered =>
'This one has already been delivered. Nothing left to start.',
ConsignmentState.rtoInitiated || ConsignmentState.returnedToSender =>
'This one is going back to the sender, so there is no delivery to '
'start.',
ConsignmentState.missing || ConsignmentState.damaged =>
'Your office has flagged a problem with this parcel. They have to clear '
'it before it can go out.',
ConsignmentState.cancelled =>
'This one has been cancelled. There is nothing to deliver.',
ConsignmentState.outForDelivery ||
ConsignmentState.collectedByMiler ||
ConsignmentState.unknown => null,
};
}
/// The consignment state a **start-delivery** refusal names, or
/// [ConsignmentState.unknown] when it names none this build knows.
///
/// ── Reading a state out of a sentence, safely ──
///
/// Parsing prose is normally a bad idea, and this is the case where it is not,
/// because it does not depend on the prose. `POST
/// /miler/consignments/:id/start-delivery` accepts **exactly one** state —
/// `Collected_By_Miler`. So of the state names a refusal from that endpoint
/// mentions, the only one that cannot be describing where the consignment
/// actually *is* is the one that would have been accepted. Drop that, and
/// whatever is left is the answer — whichever order the words come in, and
/// however the sentence is later reworded.
///
/// This exists because the state read can fail twice: the row need not carry
/// `consignmentstatus`, and `GET /miler/consignments/:id` can 404 on an older
/// deployment. In that case the refusal itself is the only thing that knows
/// what happened, and throwing it away costs the rider a real answer.
///
/// Matches only the wire's own shape — capitalised words joined by
/// underscores — so an ordinary sentence yields nothing.
ConsignmentState consignmentStateNamedInRefusal(String message) {
if (message.isEmpty) return ConsignmentState.unknown;
for (final m in RegExp(
r'[A-Za-z]+(?:_[A-Za-z]+)+',
).allMatches(message)) {
final state = consignmentStateFromRaw(m.group(0));
if (state == ConsignmentState.unknown) continue;
// The state the endpoint requires, not the state it found.
if (state == ConsignmentState.collectedByMiler) continue;
return state;
}
return ConsignmentState.unknown;
}
/// True when [message] is the backend talking to itself.
///
/// A refusal carrying a wire enum — `Inwarded_At_Hub`, `Out_for_Delivery` — is
/// a state-machine assertion, not a sentence for a rider, and it must never
/// reach a screen. Anything else the server says is prose written for a person
/// and is worth more than a generic apology, so it is still passed through.
bool namesWireState(String message) =>
RegExp(r'[A-Za-z]+(?:_[A-Za-z]+)+').hasMatch(message);
/// What the app is allowed to do with a consignment, decided from the
/// authoritative server state rather than from a booking row or a local flag.
enum DeliverGate {
/// `Out_for_Delivery` — post the delivery.
deliverable,
/// `Delivered` — the server already has it. Reconcile locally; do not post
/// again and do not show the rider an error for work he completed.
alreadyDelivered,
/// Collected but the round has not been started. The rider unblocks this
/// himself — **Start round** on the Deliveries tab.
needsRelease,
/// A real hub-side hold. Block, and say so.
awaitingHub,
/// `Created` — converted, never released, never inwarded. The parcel is in
/// this rider's hands, so the hub-hold wording is a lie; but it is not
/// `Out_for_Delivery` either, so `deliver` will refuse it. Blocked, with the
/// one sentence that is actually true about it. See
/// [ConsignmentStateX.awaitsHubInward].
awaitingInward,
/// Closed some other way (cancelled, returned). Not deliverable, not an
/// error the rider caused.
closed,
/// The state could not be read — no id, no network, an unparseable answer.
/// **Not a block.** A read failure is not evidence of anything, so the
/// delivery is attempted and the server remains the judge. Blocking here
/// would strand a rider at a door because a GET timed out.
unknown,
}
/// Reads a consignment's authoritative state and answers what may be done.
///
/// ── Why this needs a network call at all ──
///
/// Nothing the rider's device already holds can answer it. `GET /miler/bookings`
/// carries the *booking* status (terminal at `Converted_To_Consignment`) and no
/// consignment status at all — verified against the live API. The local
/// collected/out-for-delivery sets record what the *rider* did on *this*
/// handset, which is exactly what a reinstall, a second device or a
/// hub-side change makes wrong.
///
/// `GET /miler/consignments/:consignmentid` reports the current state directly
/// — shipped 21 Aug 2026 at this app's request. Before it, the only route that
/// carried consignment state was `…/logs/:id`, and reading a state machine
/// meant pulling its entire history and sorting it. That still works and
/// remains the fallback here, because a rider mid-round on a build that meets
/// an older deployment must not be blocked by a 404.
class ConsignmentGate {
ConsignmentGate._();
/// Reads the current state of [consignmentId].
///
/// Returns [ConsignmentState.unknown] on any failure — see [DeliverGate].
static Future<ConsignmentState> stateOf(Object consignmentId) async {
final id = consignmentId.toString().trim();
if (id.isEmpty || id == '0') return ConsignmentState.unknown;
try {
final res = await MilerApi.consignment(id);
if (res.ok) {
final state = _stateFromDetail(res.data);
if (state != ConsignmentState.unknown) {
debugPrint('[CONSIGNMENT] $id is ${state.name}');
return state;
}
} else {
debugPrint('[CONSIGNMENT] get $id -> ${res.status} ${res.message}');
}
// Either the route is not deployed yet, or it answered something this
// build cannot read. The history still holds the answer.
return _stateFromLogs(id);
} catch (e) {
debugPrint('[CONSIGNMENT] could not read $id: $e');
return ConsignmentState.unknown;
}
}
/// Reads `GET /miler/consignments/:id`.
///
/// The response carries both a `status` string and the derived booleans
/// (`collected`, `out_for_delivery`, `delivered`, `can_deliver`…). The
/// string is preferred: it is the state itself, whereas the flags are the
/// server's opinion *about* the state and can be extended independently.
/// The flags are only consulted when the string is a word this build has
/// never heard of — and there, `delivered` first, because mistaking a
/// completed delivery for an open one is the failure that makes a rider
/// re-post work he has already done.
static ConsignmentState _stateFromDetail(Map<String, dynamic> data) {
if (data.isEmpty) return ConsignmentState.unknown;
final raw = data['consignmentstatus'] ?? data['status'] ?? data['state'];
final named = consignmentStateFromRaw(raw);
if (named != ConsignmentState.unknown) return named;
bool flag(String key) => data[key] == true || '${data[key]}' == 'true';
// State flags first — they describe where the consignment *is*.
if (flag('delivered')) return ConsignmentState.delivered;
if (flag('out_for_delivery')) return ConsignmentState.outForDelivery;
if (flag('collected')) return ConsignmentState.collectedByMiler;
// Then the permission flags, which describe what may be *done*. A weaker
// signal — `can_deliver` is the server having already decided the answer
// this app derives from the state — but a far better one than giving up:
// `unknown` blocks the Start delivery bar and makes the door guess.
if (flag('can_deliver')) return ConsignmentState.outForDelivery;
if (flag('can_start_delivery')) return ConsignmentState.collectedByMiler;
return ConsignmentState.unknown;
}
/// The pre-21-Aug-2026 read: the whole history, newest row wins.
static Future<ConsignmentState> _stateFromLogs(String id) async {
final res = await MilerApi.consignmentLogs(id);
if (!res.ok) {
debugPrint('[CONSIGNMENT] logs $id -> ${res.status} ${res.message}');
return ConsignmentState.unknown;
}
// Rows arrive oldest-first; the state is whatever happened last. Sorted
// on `historyid` rather than trusting arrival order, because a state
// machine read out of order is worse than not read.
final rows = <Map<String, dynamic>>[
for (final r in res.list)
if (r is Map) r.map((k, v) => MapEntry(k.toString(), v)),
];
if (rows.isEmpty) return ConsignmentState.unknown;
rows.sort((a, b) {
final ai = int.tryParse('${a['historyid'] ?? 0}') ?? 0;
final bi = int.tryParse('${b['historyid'] ?? 0}') ?? 0;
if (ai != bi) return ai.compareTo(bi);
return (a['createdat'] ?? '').toString().compareTo(
(b['createdat'] ?? '').toString(),
);
});
final state = consignmentStateFromRaw(
rows.last['eventstatus'] ?? rows.last['status'],
);
debugPrint('[CONSIGNMENT] $id is ${state.name} (from logs)');
return state;
}
/// [_stateFromDetail], reachable from a test.
///
/// The flag-reading path is the one that runs when the backend adds a state
/// this build has never heard of — the case that cannot be produced by
/// naming a status, and is exactly the case worth pinning.
@visibleForTesting
static ConsignmentState stateFromDetailForTest(Map<String, dynamic> data) =>
_stateFromDetail(data);
/// Maps a state to what the delivery flow may do about it.
static DeliverGate gateFor(ConsignmentState state) {
if (state.isDeliverable) return DeliverGate.deliverable;
if (state.isDelivered) return DeliverGate.alreadyDelivered;
if (state.needsRelease) return DeliverGate.needsRelease;
if (state.awaitsHub) return DeliverGate.awaitingHub;
if (state.awaitsHubInward) return DeliverGate.awaitingInward;
if (state.isClosed) return DeliverGate.closed;
return DeliverGate.unknown;
}
/// Convenience: read and classify in one call.
static Future<DeliverGate> gateOf(Object consignmentId) async =>
gateFor(await stateOf(consignmentId));
}

View File

@@ -0,0 +1,175 @@
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHAT THE CONSOLE NEEDS TO SEE ABOUT THE HANDSET
///
/// The dispatcher watching a rider is not only asking *where is he*. When a
/// rider stops reporting, the question is **why** — and the answers are all
/// facts about the phone: the battery died, the signal dropped, he turned
/// location off, the app went to the background and the OS stopped waking it.
/// `POST /miler/logs` has fields for every one of them:
///
/// ```
/// battery is_charging connection location_service accuracy is_background
/// ```
///
/// ── Why the console was showing an em dash for all of it ──
///
/// Not because nothing was measured. Every one of these was already being read
/// on the heartbeat — and published to **MQTT only**, in a block that ran
/// *after* the log had been posted. So the telemetry existed, reached a
/// different transport, and the rider-log row the console actually reads
/// carried nothing but a position.
///
/// It was two lanes for one fact, and the lane the console reads was the empty
/// one. This is the reader both now share: one place that knows how to ask the
/// handset, so the MQTT publish and the log post cannot report different
/// batteries a second apart.
///
/// ── Never throws, never blocks ──
///
/// Telemetry is the least important thing the app does. A permissions prompt, a
/// platform channel that hangs, a plugin missing on a desktop test host — none
/// of them may cost the rider his heartbeat, so every read is guarded
/// individually and an unavailable one simply does not appear in the map.
/// A field that is absent renders as the em dash it already rendered as; a
/// field that is *wrong* would be read as fact by somebody making a decision.
/// ─────────────────────────────────────────────────────────────────────────
class DeviceTelemetry {
/// Battery percentage, or null when the platform will not say.
final int? battery;
/// True while on the charger — a rider at 12% and charging is not the same
/// problem as a rider at 12% and riding.
final bool? isCharging;
/// `WiFi`, `4G` or `none` — the contract's vocabulary, not the plugin's.
/// See [_connectionName].
final String? connection;
/// `enabled` or `disabled`. See [_locationServiceName].
///
/// The one field here that is about a *choice the rider made*, which is why
/// it is worth a column of its own on the console: a disabled location
/// service explains a silent rider completely, and no amount of staring at
/// his last position does.
final String? locationService;
const DeviceTelemetry({
this.battery,
this.isCharging,
this.connection,
this.locationService,
});
/// Reads what the handset will say about itself, right now.
///
/// [locationService] can be supplied by a caller that has just resolved a
/// fix and already knows — the position lookup has to ask the same question
/// to get a fix at all, and asking twice can disagree with itself when the
/// rider toggles the setting between the two calls.
static Future<DeviceTelemetry> read({String? locationService}) async {
int? level;
bool? charging;
try {
final battery = Battery();
level = await battery.batteryLevel;
final state = await battery.batteryState;
charging =
state == BatteryState.charging || state == BatteryState.full;
} catch (e) {
debugPrint('[TELEMETRY] battery unavailable: $e');
}
String? connection;
try {
final result = await Connectivity().checkConnectivity();
connection = _connectionName(result);
} catch (e) {
debugPrint('[TELEMETRY] connectivity unavailable: $e');
}
var service = locationService;
if (service == null || service.isEmpty || service == 'unknown') {
try {
service = await Geolocator.isLocationServiceEnabled()
? 'enabled'
: 'disabled';
} catch (e) {
debugPrint('[TELEMETRY] location service unavailable: $e');
service = null;
}
}
service = _locationServiceName(service);
return DeviceTelemetry(
battery: level,
isCharging: charging,
connection: connection,
locationService: service,
);
}
/// `WiFi` / `4G` / `none`, which is the vocabulary `/miler/logs` documents.
///
/// ── The plugin's own spelling is not the contract's ──
///
/// This was `result.first.toString().split('.').last`, which is the *enum
/// constant* — `wifi`, `mobile`, `ethernet`, `vpn`, `bluetooth`, `other`.
/// Two of those happen to look right in lower case and the rest do not
/// appear in the contract at all, so the console's Connection column was
/// rendering whatever `connectivity_plus` happened to call the transport
/// this release.
///
/// `first` was also the wrong pick: the list is every active transport, so a
/// phone on Wi-Fi with mobile data up could report either depending on the
/// order the platform returned them. Wi-Fi wins where both are present,
/// because it is the one that explains a rider whose data has run out.
static String _connectionName(List<ConnectivityResult> results) {
if (results.isEmpty) return 'none';
if (results.contains(ConnectivityResult.wifi)) return 'WiFi';
if (results.contains(ConnectivityResult.mobile)) return '4G';
if (results.contains(ConnectivityResult.ethernet)) return 'ethernet';
if (results.every((r) => r == ConnectivityResult.none)) return 'none';
return 'other';
}
/// `enabled` or `disabled` — the two values the contract names.
///
/// The richer answers the fix lookup produces — `denied`, `denied_forever`,
/// `unknown` — are more useful to a dispatcher, and they are not what this
/// field accepts. On a backend that validates its enums (and this one does
/// silently: see the `Break` / `On_Break` note in [MilerApi]) an unrecognised
/// value risks the whole row, which costs the battery and the connection
/// alongside it.
///
/// So anything that is not `enabled` is `disabled` here, which is the fact
/// the column exists to report — this rider's location is not reaching us.
/// The distinction between *off* and *denied* is not lost: it still rides on
/// the MQTT `location_turned_off` alert as `error_type`.
static String? _locationServiceName(String? raw) {
if (raw == null || raw.isEmpty) return null;
return raw == 'enabled' ? 'enabled' : 'disabled';
}
/// The heartbeat payload's own field names, ready to spread into it.
///
/// Only what was actually read: an absent key is a field the console draws as
/// unknown, which is the truth. A `0` battery or a `none` connection standing
/// in for "could not ask" is a fact somebody would act on.
Map<String, dynamic> toPayload() => <String, dynamic>{
if (battery != null) 'battery': '$battery',
if (isCharging != null) 'is_charging': isCharging,
if (connection != null && connection!.isNotEmpty) 'connection': connection,
if (locationService != null && locationService!.isNotEmpty)
'location_service': locationService,
};
@override
String toString() =>
'battery=$battery charging=$isCharging '
'connection=$connection location=$locationService';
}

578
lib/data/geofence.dart Normal file
View File

@@ -0,0 +1,578 @@
/// ─────────────────────────────────────────────────────────────────────────
/// THE PROXIMITY FENCE — one radius, one policy, one answer
///
/// A rider may not tell the hub he is at a door he is not at. Every
/// location-sensitive rung — arrived, picked, picked up, delivery arrived,
/// delivered, handed over — is measured here and nowhere else.
///
/// ── Why this is its own file ──
///
/// The measurement used to live inside `PickupsController`, which cost three
/// things:
///
/// * it could only be reached from a `GetxController`, so Home's bulk gate
/// grew a second copy with a different radius (500 m against the
/// controller's 100, later 10);
/// * it could not be tested without a real `Geolocator`, so the arithmetic was
/// never exercised at the values that decide a rider's day — 99 m and 101 m;
/// * it returned a bare `bool`, so a screen could not tell "he is 240 m away"
/// from "his GPS is off", and showed one sentence for both.
///
/// This returns a [GeofenceDecision] carrying `allowed`, `distanceMetres`,
/// `reason`, `accuracyMetres` and `timestamp`, and takes its position through
/// an injectable seam ([positionProvider]) so the whole table is a unit test
/// rather than a walk around a car park.
///
/// ── THE RULE ──
///
/// distance > kGeofenceRadiusMetres → BLOCKED, and no API call is made.
///
/// Blocked means blocked. A caller that receives `allowed == false` must return
/// before it builds a payload — not warn and continue. `geofence_test.dart`
/// asserts that with a `MockClient` that fails the test if any request is sent.
///
/// ── Fail CLOSED ──
///
/// Every failure to *measure* is a refusal: no permission, no fix, a stale one,
/// a vague one, a throw from the platform channel, and a booking carrying no
/// coordinates. The previous implementation allowed on four of those six, which
/// made the fence decorative — switching location off opened every rung in the
/// app.
/// ─────────────────────────────────────────────────────────────────────────
library;
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
/// The one switch, and it is **ON**.
///
/// ── The default changed on 2026-09-16, and it is not to change back ──
///
/// This was `defaultValue: false`, so every release build shipped with the
/// fence off and `_checkGeofence` returned `true` on its third line. "Picked
/// up" meant the rider pressed a button, not that he was there.
///
/// The define survives as an **ops kill switch**, not a development
/// convenience: if the fence starts refusing real work at real doors, a build
/// with `--dart-define=ENFORCE_GEOFENCE=false` gets the fleet moving again
/// within one release rather than one sprint. Every bypassed check logs, in
/// every build mode, so a build's own log says which way it was compiled.
///
/// Raise [kGeofenceRadiusMetres] before reaching for this.
const bool kGeofenceEnforced = bool.fromEnvironment(
'ENFORCE_GEOFENCE',
defaultValue: true,
);
/// How close "at the stop" is, in metres.
///
/// ── Why 100 and not 10 ──
///
/// This was 10 m for one day. Ten metres is at or inside the error radius of
/// consumer GPS: a phone reporting ±15 m in the open and ±40 m between
/// buildings cannot resolve a 10 m fence at all, so the control stopped
/// measuring proximity and started measuring whether the satellites were kind.
/// That is the exact failure that got the fence switched off twice before.
///
/// 100 m is the operational radius: comfortably outside normal GPS error, and
/// tight enough that a rider two streets away is refused. It is one number,
/// here, and it is deliberately **not** read from the server's `pickupradius` —
/// that made the app's one honesty control a per-tenant value nobody on this
/// side could see, and it silently disagreed with a hardcoded 500 in Home's own
/// gate.
const double kGeofenceRadiusMetres = 100;
/// How stale a fix may be before it is not a measurement.
///
/// On a round, a stale position is reliably the *previous* stop. Anything much
/// longer than this and the fence measures from the last door.
const Duration kGeofenceFixMaxAge = Duration(seconds: 30);
/// The worst fix the fence will decide on, in metres.
///
/// ── Rejected, not credited ──
///
/// The previous implementation credited the phone's error to the rider —
/// `distance - accuracy > radius` — so a ±250 m fix bought 250 m of slack and a
/// rider a quarter-kilometre away was waved through. Against a 100 m fence that
/// inverts the control: the worse the fix, the easier it is to pass.
///
/// A fix too vague to decide with is a fix the app must not decide with. At or
/// under this it is used raw; over it the rung is refused with "step into the
/// open", which is a thing the rider can act on. 50 m is generous against a
/// 100 m fence — typical urban Android fixes are 5–30 m — and it leaves the
/// fence measuring distance rather than luck.
const double kGeofenceMaxAccuracyMetres = 50;
/// How long to wait for the GPS to settle before giving up on a live fix.
const Duration kGeofenceFixTimeout = Duration(seconds: 8);
/// How long one live fix may serve several checks.
///
/// ── Why this exists: a bulk action is ONE press at ONE place ──
///
/// A rider ticking twenty bags at a counter and pressing "Picked up" produces
/// twenty calls to [Geofence.check], and without this each one took its own
/// `LocationAccuracy.best` fix with an 8-second ceiling. Twenty GPS settles for
/// a man who has not moved: 40–160 seconds of a rider standing at a counter
/// watching a spinner, which is most of the "status updates take two minutes"
/// report.
///
/// Worse, the bulk arrival gate on Home worked around it with a 3-second
/// `.timeout(..., onTimeout: () => everything is inside the fence)`. Twenty
/// sequential fixes can never finish in three seconds, so that timeout fired
/// essentially every time and waved every order through — a hole straight
/// through the 100 m rule. One fix closes the hole by making the checks
/// instant, which is the better fix in both senses.
///
/// ── Why fifteen seconds ──
///
/// It is the window in which "where the rider is" has not meaningfully changed
/// against a 100 m fence. Walking pace is ~1.4 m/s, so fifteen seconds is about
/// 20 m of possible drift — a fifth of the radius, and well inside
/// [kGeofenceFixMaxAge], which already governs how old a position may be before
/// it stops being a measurement. Riding away from a stop at 30 km/h covers
/// 125 m in the same window, so a rider who genuinely leaves is outside the
/// fence on the next fix rather than the cached one: the cache is short enough
/// that it cannot carry him to the next door.
const Duration kGeofenceFixReuse = Duration(seconds: 15);
/// Why the fence answered the way it did.
enum GeofenceOutcome {
/// Inside the radius. The only outcome that allows.
inside,
/// Measured, and too far. [GeofenceDecision.distanceMetres] says how far.
outside,
/// The booking carries no usable pin, so proximity cannot be checked.
///
/// **Refused.** This used to be allowed-and-logged on the argument that the
/// rider cannot fix the hub's data from a doorstep. True, and it still made
/// the fence optional: a booking with a blank latitude opened every rung on
/// that stop. The office can fix a missing pin in a minute and the rider is
/// told to ring them.
noTarget,
/// Location services are switched off on the handset.
serviceDisabled,
/// The rider has not granted location permission.
permissionDenied,
/// Permission is denied permanently; only Settings can restore it.
permissionDeniedForever,
/// The GPS did not produce a fix in [kGeofenceFixTimeout] and there is no
/// recent cached one.
timeout,
/// No fix at all, and not specifically a timeout.
noFix,
/// The only fix available is older than [kGeofenceFixMaxAge].
staleFix,
/// The fix is worse than [kGeofenceMaxAccuracyMetres].
poorAccuracy,
/// The platform threw. Refused — see the class note on failing closed.
error,
}
/// The fence's answer about one rung at one stop.
@immutable
class GeofenceDecision {
const GeofenceDecision({
required this.allowed,
required this.outcome,
required this.timestamp,
this.distanceMetres,
this.accuracyMetres,
this.reason,
this.riderLat,
this.riderLng,
});
/// May the caller proceed to the API call?
///
/// True for exactly one outcome — [GeofenceOutcome.inside]. Everything else
/// is a refusal, including every way of failing to measure.
final bool allowed;
final GeofenceOutcome outcome;
/// Metres from the rider to the stop, or null when it could not be measured.
final double? distanceMetres;
/// The error radius the phone reported on the fix used, in metres.
final double? accuracyMetres;
/// When the decision was taken.
final DateTime timestamp;
/// The fix the decision was taken on, when there was one.
///
/// Carried so a caller that has just been allowed through can stamp the same
/// position onto its payload instead of asking the GPS a second time. Two
/// fixes seconds apart are two different answers, and the one the fence
/// judged is the one the hub should be told about.
final double? riderLat;
final double? riderLng;
/// One sentence, in the rider's words, naming what he must do. Null only when
/// [allowed] and there is nothing to say.
///
/// Never a developer error, a status code, or a coordinate pair.
final String? reason;
bool get isBlocked => !allowed;
@override
String toString() =>
'GeofenceDecision(${outcome.name}, allowed=$allowed, '
'distance=${distanceMetres?.toStringAsFixed(1)}m, '
'accuracy=${accuracyMetres?.toStringAsFixed(0)}m)';
}
/// A position reading, or the reason there is not one.
@immutable
class GeofenceFix {
const GeofenceFix({this.position, this.failure});
/// A reading good enough to hand to the fence.
final Position? position;
/// Why there is none. Ignored when [position] is set.
final GeofenceOutcome? failure;
bool get hasPosition => position != null;
}
/// Measures one rung against one stop.
///
/// Stateless and static on purpose: Home's bulk gate and the per-stop rung in
/// `PickupsController` are different code paths that must never answer
/// differently, and the surest way to guarantee that is for there to be nothing
/// to construct differently.
abstract final class Geofence {
/// How the fence gets the rider's position. Swapped in tests.
///
/// Kept as a settable function rather than a constructor argument because the
/// call sites are spread across controllers and widgets with no shared owner
/// to thread an instance through — and the alternative on offer was another
/// copy of the arithmetic.
static Future<GeofenceFix> Function() positionProvider = _devicePosition;
/// Restores the real device provider and drops any cached fix.
/// Call in `tearDown`.
static void useDevice() {
positionProvider = _devicePosition;
resetCache();
}
/// The last live fix, and when it was taken. See [kGeofenceFixReuse].
static Position? _cachedFix;
static DateTime? _cachedAt;
/// Forgets the cached fix.
///
/// Called on sign-out and from tests. Also worth calling if a screen knows
/// the rider has travelled — though it should rarely be needed, because the
/// window is shorter than any journey between two stops.
static void resetCache() {
_cachedFix = null;
_cachedAt = null;
}
/// True when a coordinate pair is a usable point on Earth.
///
/// `0,0` is in the Gulf of Guinea and is what every unset latitude in this
/// codebase decays to, so it is treated as absent rather than as a place.
static bool isUsable(double? lat, double? lng) =>
lat != null &&
lng != null &&
lat != 0 &&
lng != 0 &&
lat.abs() <= 90 &&
lng.abs() <= 180;
/// Metres between two points on the WGS-84 ellipsoid.
///
/// Delegates to [Geolocator.distanceBetween] — never a latitude/longitude
/// comparison and never a flat-plane subtraction, both of which have been
/// tried in this app's history and are wrong by a factor that varies with
/// where the rider is standing.
static double distanceBetween(
double targetLat,
double targetLng,
double riderLat,
double riderLng,
) => Geolocator.distanceBetween(targetLat, targetLng, riderLat, riderLng);
/// The fence, for one [action] at one target.
///
/// [action] is the rung's name in the rider's vocabulary — "Arrived",
/// "Delivered", "Handed over" — and is quoted back in
/// [GeofenceDecision.reason] so one sentence serves every rung.
static Future<GeofenceDecision> check({
required double? targetLat,
required double? targetLng,
required String action,
double radiusMetres = kGeofenceRadiusMetres,
}) async {
final now = DateTime.now();
final verb = action.toLowerCase();
// Logged with `debugPrint` in *every* build mode, deliberately. The whole
// risk of this switch is a build going out with proximity silently off, so
// the one thing it must never be is quiet.
if (!kGeofenceEnforced) {
debugPrint(
'[GEOFENCE] OFF for $action — this build was compiled with '
'ENFORCE_GEOFENCE=false. Stop completion is NOT proximity-verified.',
);
return GeofenceDecision(
allowed: true,
outcome: GeofenceOutcome.inside,
timestamp: now,
);
}
// ── No pin on the booking: REFUSED ──
//
// Checked before the phone is asked for anything, because no fix can answer
// a question with no target and the rider should not wait 8s to be told so.
if (!isUsable(targetLat, targetLng)) {
final decision = GeofenceDecision(
allowed: false,
outcome: GeofenceOutcome.noTarget,
timestamp: now,
reason:
'This stop has no map location on it, so Miler cannot confirm you '
'are there. Ring your office and ask them to add the address '
'location.',
);
debugPrint(
'[GEOFENCE] $action refused: stop carries no coordinates '
'($targetLat, $targetLng) — data gap on the booking',
);
return decision;
}
final GeofenceFix fix;
try {
fix = await positionProvider();
} catch (e) {
// ── A throw is a refusal ──
//
// The old implementation caught here and returned `true` with the comment
// "allow on error (fail-safe)". That is fail-*open*: any throw from the
// location stack — and the permission plugin throws rather than returning
// on several OEM builds — opened every rung in the app.
debugPrint('[GEOFENCE] $action refused: position provider threw: $e');
return GeofenceDecision(
allowed: false,
outcome: GeofenceOutcome.error,
timestamp: now,
reason: _reasonForFixFailure(GeofenceOutcome.error, verb),
);
}
if (!fix.hasPosition) {
final outcome = fix.failure ?? GeofenceOutcome.noFix;
final decision = GeofenceDecision(
allowed: false,
outcome: outcome,
timestamp: now,
reason: _reasonForFixFailure(outcome, verb),
);
debugPrint('[GEOFENCE] $action refused: $decision');
return decision;
}
final pos = fix.position!;
final accuracy = pos.accuracy;
// ── The rider's own position must be a place ──
//
// A plugin that answers `0,0` rather than throwing is what this catches;
// without it the fence measures from the Gulf of Guinea and refuses every
// stop in India with a distance in the thousands of kilometres.
if (!isUsable(pos.latitude, pos.longitude)) {
final decision = GeofenceDecision(
allowed: false,
outcome: GeofenceOutcome.noFix,
timestamp: now,
accuracyMetres: accuracy,
reason: _reasonForFixFailure(GeofenceOutcome.noFix, verb),
);
debugPrint('[GEOFENCE] $action refused: $decision');
return decision;
}
final distance = distanceBetween(
targetLat!,
targetLng!,
pos.latitude,
pos.longitude,
);
// ── A fix too vague to decide with ──
//
// Refused rather than credited. Checked *after* the distance is computed so
// the decision still carries the measurement for the log, but before the
// radius comparison so a vague fix can never pass one. See
// [kGeofenceMaxAccuracyMetres].
if (accuracy > kGeofenceMaxAccuracyMetres) {
final decision = GeofenceDecision(
allowed: false,
outcome: GeofenceOutcome.poorAccuracy,
timestamp: now,
distanceMetres: distance,
accuracyMetres: accuracy,
riderLat: pos.latitude,
riderLng: pos.longitude,
reason:
'Your phone is not sure where you are yet. Step into the open for a '
'moment, then mark this stop $verb.',
);
debugPrint('[GEOFENCE] $action refused: $decision');
return decision;
}
// ── Raw distance against the radius ──
//
// Not `distance - accuracy`. The slack that arithmetic granted grew with
// the phone's own error, so the worse the fix the further out a rider could
// stand — the control running backwards. The error is handled once, above,
// by refusing to decide on a fix that cannot resolve 100 m.
final inside = distance <= radiusMetres;
final decision = GeofenceDecision(
allowed: inside,
outcome: inside ? GeofenceOutcome.inside : GeofenceOutcome.outside,
timestamp: now,
distanceMetres: distance,
accuracyMetres: accuracy,
riderLat: pos.latitude,
riderLng: pos.longitude,
reason: inside ? null : _tooFarSentence(distance, radiusMetres, verb),
);
debugPrint(
'[GEOFENCE] $action | target ($targetLat, $targetLng) '
'| rider (${pos.latitude}, ${pos.longitude}) | $decision',
);
return decision;
}
/// "You're 240 m away. Move within 100 m to mark this stop arrived."
///
/// Distance first, because that is the number the rider acts on, and in the
/// unit he thinks in — metres under a kilometre, kilometres over it.
static String _tooFarSentence(double distance, double radius, String verb) {
final away = distance >= 1000
? '${(distance / 1000).toStringAsFixed(1)} km'
: '${distance.round()} m';
return "You're $away away. Move within ${radius.round()} m to mark this "
'stop $verb.';
}
static String _reasonForFixFailure(GeofenceOutcome outcome, String verb) =>
switch (outcome) {
GeofenceOutcome.serviceDisabled =>
'Location is switched off on your phone. Turn it on, then mark this '
'stop $verb.',
GeofenceOutcome.permissionDenied =>
'Miler needs your location to confirm you are at this stop. Allow '
'location access, then mark it $verb.',
GeofenceOutcome.permissionDeniedForever =>
'Location access is blocked for Miler. Open Settings → Permissions → '
'Location and allow it, then mark this stop $verb.',
GeofenceOutcome.staleFix =>
'Your phone last found you a while ago. Wait a moment for it to catch '
'up, then mark this stop $verb.',
GeofenceOutcome.timeout =>
'Your phone is still looking for your location. Step into the open, '
'wait a moment, then mark this stop $verb.',
_ =>
'Your phone could not find your location, so this stop cannot be '
'marked $verb. Turn location on, allow it for Miler, and step '
'outside if you can.',
};
/// The real device fix: service, permission, then the best reading the
/// hardware will give inside [kGeofenceFixTimeout].
///
/// Falls back to a cached position **only** when it is fresher than
/// [kGeofenceFixMaxAge]; a last-known fix has no age limit of its own and on
/// a round it is reliably the previous stop.
static Future<GeofenceFix> _devicePosition() async {
// ── One press at one place is one fix ──
//
// Checked before the service and permission calls, because those are
// platform-channel round trips too and a batch of twenty pays them twenty
// times for an answer that cannot have changed. A cached fix is only ever
// stored after a *successful* live read, so this can never serve a stale or
// vague one — the age check below is the only way in.
final cached = _cachedFix;
final at = _cachedAt;
if (cached != null &&
at != null &&
DateTime.now().difference(at) <= kGeofenceFixReuse) {
return GeofenceFix(position: cached);
}
try {
if (!await Geolocator.isLocationServiceEnabled()) {
return const GeofenceFix(failure: GeofenceOutcome.serviceDisabled);
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever) {
return const GeofenceFix(
failure: GeofenceOutcome.permissionDeniedForever,
);
}
if (permission == LocationPermission.denied) {
return const GeofenceFix(failure: GeofenceOutcome.permissionDenied);
}
try {
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
// `best`, not `low`. A `low` fix is a ~1 km hint on Android; against
// a 100 m fence that is not a degraded measurement, it is noise
// being treated as evidence.
accuracy: LocationAccuracy.best,
timeLimit: kGeofenceFixTimeout,
),
);
_cachedFix = pos;
_cachedAt = DateTime.now();
return GeofenceFix(position: pos);
} catch (_) {
// No live fix. A recent cached one is a measurement; a stale one is not.
final cached = await Geolocator.getLastKnownPosition();
if (cached == null) {
return const GeofenceFix(failure: GeofenceOutcome.timeout);
}
final age = DateTime.now().difference(cached.timestamp);
if (age > kGeofenceFixMaxAge) {
debugPrint(
'[GEOFENCE] cached fix is ${age.inSeconds}s old — too stale to '
'measure a ${kGeofenceRadiusMetres.round()}m fence',
);
return const GeofenceFix(failure: GeofenceOutcome.staleFix);
}
return GeofenceFix(position: cached);
}
} catch (e) {
debugPrint('[GEOFENCE] position lookup threw: $e');
return const GeofenceFix(failure: GeofenceOutcome.error);
}
}
}

89
lib/data/heartbeat.dart Normal file
View File

@@ -0,0 +1,89 @@
/// ─────────────────────────────────────────────────────────────────────────
/// HOW OFTEN THE RIDER LOG BEATS
///
/// ── Why this constant exists ──
///
/// The heartbeat's cadence came from one place: `logseconds`, a field the
/// **legacy** login returned and the app persisted at sign-in. The v1 contract
/// at `api.doormile.com` does not send it — `POST /miler/verify-pin` answers
/// with `{success, token, user:{…, profile:{…}}}` and nothing about logging
/// cadence — so the adapter in `AuthProvider` had no value to map and wrote the
/// `?? 0` fallback into prefs.
///
/// Zero was then read as an instruction rather than as an absence. Both call
/// sites treated it as "do not beat":
///
/// • `RiderLogController.setOnDuty` started the loop only `if (interval > 0)`.
/// • `startAutoCreateLoginLoop` computed `baseInterval = 0` and returned at
/// `if (interval <= 0)`.
///
/// So on every v1 deployment the periodic loop never started. What went with
/// it was not just telemetry: `_heartbeat()` in the rider-log provider is the
/// one place that writes **both** `POST /miler/logs` (the trail the console's
/// Battery, Charging, Connection, GPS Accuracy and Location Service columns are
/// read from) and `PUT /miler/location` (the Redis geo-index dispatch searches
/// to find a rider at all). A rider on duty was reporting neither, and the
/// console drew an em dash against a phone that was measuring all of it
/// correctly — see [DeviceTelemetry], which was never the problem.
///
/// The only reason it was not total silence is that a live pickup forces the
/// interval to 30 by a separate path, so a rider mid-collection beat and a
/// rider between stops did not.
///
/// ── Why 30 seconds ──
///
/// It is the cadence the rest of the app already assumes when nobody has told
/// it otherwise: the pickup log's own `_getLogInterval` falls back to 30, and
/// both controllers hard-code 30 for the live-pickup case. Matching it means a
/// rider's location trail has one shape rather than two, and a hub that later
/// starts sending `logseconds` still wins — this is a floor under a missing
/// answer, not a replacement for a real one.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// Seconds between rider-log heartbeats when the backend has not said.
const int kDefaultLogSeconds = 30;
/// The heartbeat interval to actually use, given whatever the backend said.
///
/// A positive value is honoured exactly. `null`, a zero, a negative, and a
/// string that is none of those all mean *the backend did not answer*, and the
/// answer to that is [kDefaultLogSeconds] — never zero, because zero is what
/// stopped the heartbeat starting in the first place.
///
/// Accepts an [Object] rather than an `int?` because the value arrives from two
/// different shapes: `prefs.getInt('logseconds')` gives an `int?`, while the
/// login envelope's `details['logseconds']` is untyped JSON and has been seen
/// as both a number and a string.
int resolveLogSeconds(Object? configured) {
final int? parsed = switch (configured) {
final int n => n,
final num n => n.toInt(),
final String s => int.tryParse(s.trim()),
_ => null,
};
return (parsed != null && parsed > 0) ? parsed : kDefaultLogSeconds;
}
/// The `status` a heartbeat reports, given whether the rider has live work.
///
/// ── `active` and `idle` are not words this contract knows ──
///
/// Four call sites built this string inline as `hasActivePickups ? 'active' :
/// 'idle'`, and neither value appears anywhere in the API. `status` on
/// `POST /miler/logs` takes an **availability** value — the set
/// [MilerApi.availabilityStatuses] lists, and the same set
/// `PUT /miler/availability` validates:
///
/// Offline · Available · Assigned · On_Pickup · At_Customer ·
/// Picked_Up · On_Delivery · Break · Blocked
///
/// This backend rejects an unrecognised enum **silently** — the `Break` /
/// `On_Break` note in [MilerApi] records the last time the obvious guess was
/// quietly dropped — so a bad `status` risks the whole row, and takes the
/// battery, the connection and the location reading down with it.
///
/// A rider working a counter is `On_Pickup`; a rider between stops is
/// `Available`. Both are values the console already renders.
String heartbeatStatus({required bool hasActiveWork}) =>
hasActiveWork ? 'On_Pickup' : 'Available';

356
lib/data/lifecycle.dart Normal file
View File

@@ -0,0 +1,356 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/api_status.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHAT THE SERVER ACTUALLY CONFIRMED
///
/// Every rung this app draws — Accepted, Arrived, Picked, Active, Delivered —
/// is a claim about what the **hub** believes. The app had no way to check
/// that claim: a mutation returned `ok`, the screen advanced, and whether the
/// office could see the same thing was never asked.
///
/// ── The bug that made this a file ──
///
/// Verified against production on 21 Aug 2026, booking 78:
///
/// ```
/// BEFORE GET /miler/bookings status = Miler_Assigned
/// CALL POST /miler/bookings/78/reached
/// → 200 {"success":true,"data":{"bookingid":78,
/// "status":"Miler_Assigned"}}
/// AFTER GET /miler/bookings status = Miler_Assigned
/// ```
///
/// `reached` **returns success and writes nothing.** It echoes the booking's
/// current status. So the rider pressed *I've arrived*, the app got `ok: true`,
/// drew ARRIVED, and the hub never heard about it — not because the app failed
/// to call, but because a 200 was taken as proof of a transition that never
/// happened.
///
/// ── The rule ──
///
/// A 200 is proof the call was accepted. It is **not** proof of a state
/// change. The only proof of a state change is the state coming back in the
/// response. So every lifecycle mutation is read through this file, which
/// answers three separate questions the caller used to conflate:
///
/// • did the call succeed? → [ApiResult.ok]
/// • did the state actually move? → [TransitionOutcome.confirmed]
/// • what is the state now? → [bookingStatus] / [consignmentState]
///
/// Nothing here blocks a rider. A backend that does not record his arrival is
/// the backend's fault, and stranding him at a kitchen over it would turn one
/// broken endpoint into a stopped operation. What it does is stop the app
/// *claiming* the hub agrees when it demonstrably does not — the claim is
/// downgraded, logged with the evidence, and surfaced.
/// ─────────────────────────────────────────────────────────────────────────
enum TransitionOutcome {
/// The response carries the state the call was supposed to produce. The rung
/// the rider sees is the rung the office sees.
confirmed,
/// The call succeeded and the state did **not** move — or moved somewhere
/// the response does not name. The rider may carry on; the app must not
/// pretend the hub knows. See [MilerLifecycle.reached].
unconfirmed,
/// The server refused. A real failure with a reason.
refused,
}
/// One lifecycle mutation, read for what it proves.
@immutable
class StateTransition {
const StateTransition({
required this.outcome,
required this.bookingStatus,
required this.consignmentState,
this.consignmentId = '',
this.nextAction = '',
this.code = '',
this.message = '',
this.evidence = '',
});
final TransitionOutcome outcome;
/// The booking status the response reported, parsed. [BookingStatus.unknown]
/// when the response named none.
final BookingStatus bookingStatus;
/// The consignment state the response reported, parsed.
final ConsignmentState consignmentState;
/// The id minted at the pivot, when this transition was one.
final String consignmentId;
/// The server's own instruction for what happens next — `start_delivery`,
/// `inward_at_hub`. Read, never assumed.
final String nextAction;
/// The stable failure code, for callers that must branch on *why*.
final String code;
final String message;
/// What the response actually said, for a log line that can be pasted into a
/// backend ticket without re-running anything.
final String evidence;
bool get isConfirmed => outcome == TransitionOutcome.confirmed;
bool get isUnconfirmed => outcome == TransitionOutcome.unconfirmed;
bool get isRefused => outcome == TransitionOutcome.refused;
/// True when the pivot left the consignment in the rider's hands awaiting a
/// **Start delivery** press — the post-flag lifecycle.
bool get awaitsStartDelivery =>
consignmentState.needsRelease || nextAction == 'start_delivery';
/// True when the pivot released the consignment itself, which is the
/// pre-flag lifecycle the backend calls compatibility mode.
///
/// **This is the reason Admin shows Active for a stop the rider just
/// picked.** Not a client bug and not something the app may paper over: the
/// consignment really is out for delivery, and a rider told otherwise would
/// be looking at a different truth from his office.
bool get isCompatibilityMode =>
consignmentState.isDeliverable && nextAction != 'start_delivery';
}
/// Reads lifecycle mutations. Pure — no I/O of its own.
abstract final class MilerLifecycle {
/// First non-empty value among [keys], searched shallow then one level in.
static String _str(Map<String, dynamic> data, List<String> keys) {
for (final k in keys) {
final v = data[k];
if (v == null || v is Map || v is List) continue;
final s = v.toString().trim();
if (s.isNotEmpty && s != 'null' && s != '0') return s;
}
for (final v in data.values) {
if (v is Map) {
final nested = v.map((k, x) => MapEntry(k.toString(), x));
final found = _str(nested, keys);
if (found.isNotEmpty) return found;
}
}
return '';
}
/// `POST /miler/bookings/:id/reached`.
///
/// Confirmed **only** when the response reports
/// [BookingStatus.arrivedAtPickup]. Anything else — including the 200 that
/// echoes the unchanged status, which is what production returns today — is
/// [TransitionOutcome.unconfirmed], and the evidence says exactly what came
/// back so the report writes itself.
static StateTransition reached(ApiResult res) {
if (!res.ok) {
return StateTransition(
outcome: TransitionOutcome.refused,
bookingStatus: BookingStatus.unknown,
consignmentState: ConsignmentState.unknown,
code: res.code,
message: res.message,
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
);
}
final raw = _str(res.data, const [
'status',
'bookingstatus',
'booking_status',
]);
final parsed = BookingStatus.parse(raw);
// ── Arrival is a timestamp, not a status ──
//
// This asked whether the booking's `status` had become
// `Arrived_At_Pickup`, and answered *unconfirmed* forever — because the
// backend team has since confirmed there is **no Arrived rung in the
// booking-status lifecycle at all**. It goes
// `pickup_scheduled → converted_to_consignment → …`, and arrival is
// recorded beside it as `reachedat`.
//
// So the app was demanding evidence of a transition the backend never
// claimed to make, and logging a gap every time it did not get it. The
// proof of arrival is the arrival stamp coming back; the status echoing
// `Miler_Assigned` or `pickup_scheduled` is correct and expected.
final stamp = _str(res.data, const [
'reachedat',
'reached_at',
'arrivedat',
]);
final confirmed = stamp.isNotEmpty;
return StateTransition(
outcome: confirmed
? TransitionOutcome.confirmed
: TransitionOutcome.unconfirmed,
bookingStatus: parsed,
consignmentState: ConsignmentState.unknown,
evidence: confirmed
? 'the response stamped the arrival at "$stamp"'
: raw.isEmpty
? 'the response carried no arrival stamp and named no status'
: 'the response carried no arrival stamp; status="$raw"',
);
}
/// `POST /miler/bookings/:id/pickup-complete`.
///
/// The pivot has two legitimate outcomes and the app must not choose between
/// them from a build-time assumption:
///
/// `Collected_By_Miler` + `next_action: start_delivery`
/// the rider holds it; **Start delivery** releases it.
/// `Out_for_Delivery`
/// compatibility mode — the pivot released it in the same call.
///
/// Both are confirmed transitions. Which one happened is [isCompatibilityMode],
/// read from the response and never from a flag mirrored into this app.
static StateTransition pickupComplete(ApiResult res) {
if (!res.ok) {
return StateTransition(
outcome: TransitionOutcome.refused,
bookingStatus: BookingStatus.unknown,
consignmentState: ConsignmentState.unknown,
code: res.code,
message: res.message,
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
);
}
final bookingRaw = _str(res.data, const [
'booking_status',
'bookingstatus',
'status',
]);
final consignmentRaw = _str(res.data, const [
'consignmentstatus',
'consignment_status',
'consignmentstate',
]);
final id = _str(res.data, const [
'consignment_id',
'consignmentid',
'consignmentId',
'consignmentno',
]);
final next = _str(res.data, const [
'next_action',
'nextaction',
]).toLowerCase();
final booking = BookingStatus.parse(bookingRaw);
var consignment = consignmentStateFromRaw(consignmentRaw);
// A response that names only the booking still answers the question when
// the booking word is one of the two that carries the delivery half.
if (consignment == ConsignmentState.unknown) {
if (booking == BookingStatus.outForDelivery) {
consignment = ConsignmentState.outForDelivery;
} else if (next == 'start_delivery') {
consignment = ConsignmentState.collectedByMiler;
}
}
// The pivot is confirmed by the id it minted. Without one there is nothing
// to deliver against, whatever the words say.
final confirmed =
id.isNotEmpty ||
booking == BookingStatus.convertedToConsignment ||
consignment != ConsignmentState.unknown;
return StateTransition(
outcome: confirmed
? TransitionOutcome.confirmed
: TransitionOutcome.unconfirmed,
bookingStatus: booking,
consignmentState: consignment,
consignmentId: id,
nextAction: next,
evidence:
'booking="$bookingRaw" consignment="$consignmentRaw" '
'id="$id" next="$next"',
);
}
/// `POST /miler/consignments/:id/inward-at-hub`.
///
/// Confirmed when the response says the parcel is now the base's —
/// `Inwarded_at_Hub`, or `already_inwarded: true` for a retry whose first
/// attempt landed and whose answer was lost.
///
/// ── Why `already_inwarded` is a success and not an error ──
///
/// A rider hands a parcel over at a loading bay, the reply is dropped, and he
/// presses again. The server has already done the work; refusing the second
/// press would tell him the hand-over failed for a parcel now sitting on the
/// base's counter, and he has no way to prove otherwise from where he is
/// standing. So the second answer confirms the first — which is exactly what
/// the backend's 200-with-a-flag shape is for, and the app must not turn it
/// back into a failure.
static StateTransition inwardAtHub(ApiResult res) {
if (!res.ok) {
return StateTransition(
outcome: TransitionOutcome.refused,
bookingStatus: BookingStatus.unknown,
consignmentState: ConsignmentState.unknown,
code: res.code,
message: res.message,
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
);
}
final raw = _str(res.data, const [
'consignmentstatus',
'consignment_status',
'status',
]);
final state = consignmentStateFromRaw(raw);
final stamp = _str(res.data, const ['inwardedat', 'inwarded_at']);
final next = _str(res.data, const ['next_action', 'nextaction']).toLowerCase();
// `_str` skips `false`-ish values by design, so the flag is read straight.
final already =
res.data['already_inwarded'] == true ||
res.data['alreadyInwarded'] == true;
final confirmed =
state == ConsignmentState.inwardedAtHub ||
already ||
next == 'handed_to_hub';
return StateTransition(
outcome: confirmed
? TransitionOutcome.confirmed
: TransitionOutcome.unconfirmed,
bookingStatus: BookingStatus.unknown,
consignmentState: state,
nextAction: next,
evidence:
'consignment="$raw" inwardedat="$stamp" next="$next" '
'already=$already',
);
}
/// One line, in the shape a backend ticket wants.
static void report(String verb, StateTransition t) {
switch (t.outcome) {
case TransitionOutcome.confirmed:
debugPrint('[LIFECYCLE][$verb] confirmed — ${t.evidence}');
case TransitionOutcome.unconfirmed:
debugPrint(
'[LIFECYCLE][$verb] NOT CONFIRMED BY THE SERVER — ${t.evidence}. '
'The call succeeded and the state did not move. This is a backend '
'deployment mismatch, not a client failure.',
);
case TransitionOutcome.refused:
debugPrint('[LIFECYCLE][$verb] refused — ${t.evidence}');
}
}
}

Some files were not shown because too many files have changed in this diff Show More