diff --git a/.gitignore b/.gitignore index 6f0d006..752c85f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,51 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# 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 -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# 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 +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/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/ diff --git a/ABOUT_MILER.md b/ABOUT_MILER.md index 5829ae6..3173feb 100644 --- a/ABOUT_MILER.md +++ b/ABOUT_MILER.md @@ -1,682 +1,1160 @@ -# Miler — the Doormile rider app +# Miler -> *Miler Rider — Smarter Pickups, Powered by AI* -> Flutter · Android + iOS · `com.doormile.partner` · v1.2.28+144 +The Doormile rider app. One Android/iOS build that a rider signs into, gets his +work for the day, and reports it back from the doorstep. -This is the single reference for what this app is, who it serves, how it is built, -and how it was designed. If you are new to the codebase, read sections 1–3. If you -are about to change UI, read sections 5–7 before you write a line. +`com.doormile.partner` · Flutter · GetX for the controllers that need it · +one backend at `https://api.doormile.com/api/v1` + +This is the only document in the repository. It covers what the app is, how the +work actually flows, the design system, every screen, and what is still wrong. --- -## Contents +# Contents -1. [What Miler is](#1-what-miler-is) -2. [The people and the workflow](#2-the-people-and-the-workflow) -3. [The routing problem underneath](#3-the-routing-problem-underneath) -4. [Architecture](#4-architecture) -5. [Design principles](#5-design-principles) +1. [What it is](#1-what-it-is) +2. [One app, two lines of work](#2-one-app-two-lines-of-work) +3. [The lifecycle](#3-the-lifecycle) +4. [The API](#4-the-api) +5. [The data layer](#5-the-data-layer) 6. [The design system](#6-the-design-system) -7. [Screen by screen — what was designed and why](#7-screen-by-screen--what-was-designed-and-why) -8. [Backend integration and known gaps](#8-backend-integration-and-known-gaps) -9. [What we are doing with this app now](#9-what-we-are-doing-with-this-app-now) -10. [Conventions for anyone touching this code](#10-conventions-for-anyone-touching-this-code) +7. [The bars](#7-the-bars) +8. [Home](#8-home) +9. [Deliveries](#9-deliveries) +10. [Activity](#10-activity) +11. [Delivery details](#11-delivery-details) +12. [Account](#12-account) +13. [Bottom sheets](#13-bottom-sheets) +14. [States every screen must survive](#14-states-every-screen-must-survive) +15. [What holds it](#15-what-holds-it) +16. [Known gaps](#16-known-gaps) --- -## 1. What Miler is +# 1. What it is -Miler is the **rider app** built by **Doormile**, a logistics company that moves -parcels between customers and its hubs. It is the tool carried by the field agent -we call a **"miler"** — the person on a bike who actually collects and delivers -packages on the ground. +Miler is the field half of Doormile. The hub console assigns work; this is what +the rider on the bike actually holds. It covers a whole shift: -The system works like this. Customers use the separate **Doormile customer app** -to book a **time slot** — a window in which they want a parcel picked up or -delivered. Behind the scenes, for each slot, a **hub manager (admin)** gathers all -the customer bookings falling in that window and **assigns them to one miler as a -single ordered route** — Stop 1, Stop 2, Stop 3, through to the last stop. That -route appears inside this app in the exact sequence the admin set. +- **Sign in** — phone number and a 4-digit MPIN, verified server-side. +- **Go on duty** — a switch in the Home app bar, not a gate at launch. +- **Take work** — accept or decline the stops the hub assigns. +- **Get there** — a map with live routing, and one-tap navigation handoff. +- **Work the door** — arrive, confirm what happened, collect cash, take proof. +- **Report** — every transition posts as it happens, with GPS breadcrumbs. +- **Get paid** — earnings, activity history, rewards. -The miler goes **On Duty**, works the stops one by one in order, and at each stop -does either a pickup or a delivery or both : +Plus what a shift needs around the edges: duty and break logging, background +location, push notifications, offline tolerance, support tickets, a profile. -- **Pickup** — collects the parcel from the customer or store, photographs it, - takes payment if money is owed (cash or UPI/QR), confirms it as picked up. -- **Delivery** — hands over a parcel carried from the hub, verifies with an **OTP - and a photo**, confirms it as delivered. Deliveries are prepaid, so no money - is collected. +### The one rule everything hangs off -He can **skip** a stop when a store is closed or a customer isn't available, and -come back to it later. He **cannot reorder** the route — the sequence is fixed by -the admin. After the last stop he **returns to the same hub he started from**, -bringing back everything he collected plus anything he couldn't deliver. +> **One order = one bag.** -In short: a **first-mile + last-mile round trip**, `Hub → stop 1 → stop 2 → … → -stop N → back to the same Hub`, much like an Amazon or Flipkart delivery associate -runs a route. This app is the miler's companion for the whole journey. +Five pickup orders from a kitchen means five bags, and then five deliveries. +There is no second quantity anywhere in the app that can disagree with the order +count — no crate figure, no aggregate bag total, no backend `Quantity` doing +double duty. -### Doormile's two apps +`lib/data/bag_manifest.dart` is the only place that answers "how many bags?" and +"which bag is this?": -| App | Who uses it | What they do | +- **The count** is `orders.length`. Derived, never stored, so it cannot drift. +- **The identity** prefers the label the kitchen printed on the physical bag and + falls back to the order's position *in its own pickup group* — `Bag 1`…`Bag 5`. + The third order from the second kitchen is that kitchen's Bag 3, not the + route's eighth, because a rider counts one shelf at a time. + +Every surface states it the same way — `5 orders · 5 bags` — because those two +numbers being equal is something the rider can *check against a shelf*. +`test/one_bag_per_order_test.dart` fails if any screen starts counting something +else. + +--- + +# 2. One app, two lines of work + +Miler serves two operations off one login. Which one a rider gets is decided by +his **tenant**, resolved once from the login response. + +| | **Milk Man** `ServiceProfile.milkMan` | **Logistics** `ServiceProfile.parcel` | |---|---|---| -| **Doormile customer app** | End customers | Book a time slot for a pickup or delivery | -| **Miler app** (this one) | The rider | Carry out the assigned route | - -### Who this app is for - -One person, on a bike, working through an assigned list of stops for a shift. -That is the entire user base. Every design decision in this document traces back -to that single user and one fact about him: **the daily loop repeats 30–50 times a -day.** - ---- - -## 2. The people and the workflow - -| Role | What they do | Which app | -|---|---|---| -| **Customer (CX)** | Books a time slot for a pickup or delivery | Doormile customer app | -| **Hub manager / Admin** | Bundles a slot's bookings into an ordered route and assigns it to a miler | Admin / hub system | -| **Miler (rider)** | Runs the route — pickups and deliveries — and returns to the hub | **This app** | - -### The loop, step by step - -1. **Slot booking** — customers book time slots in the customer app. -2. **Route assignment** — for a slot, the hub admin groups the bookings and - assigns them to one miler as a **fixed-order route** (Stop 1 … Stop N). -3. **Route appears** — the ordered stops show up in this app. -4. **On Duty** — the miler goes online to start working. -5. **Work each stop in order** - - *Pickup stop* → navigate → collect parcel → photo proof (+ weight) → - take payment if owed (Cash / UPI-QR) → confirm **Picked up**. - - *Delivery stop* → navigate → hand over parcel → verify **OTP + photo** → - confirm **Delivered** (prepaid, no payment). -6. **Skip if needed** — a stop can be skipped and resumed later. The order of the - remaining stops never changes. -7. **Move to next stop** — after each success the app prompts the next stop. -8. **Return to hub** — after the last stop, back to the **same hub** with all - collected parcels and any undelivered (RTO) items. - -### Hard rules - -- **One route per slot**, assigned by the admin. -- **Stops are strictly ordered** — the miler follows the sequence and cannot - reorder it. -- **Skip is allowed and normal** — not a failure; the stop can be resumed. -- **Round trip** — always starts and ends at the **same hub**. -- **Pickup vs Delivery** — each stop is one or the other; the app shows the type - clearly and runs the right flow. - -### Vocabulary - -Rider-facing language is deliberately narrow. **Route** (the whole assignment) → -**Stops** (ordered, each tagged Pickup or Delivery) → **Hub** (start and return -point). Avoid "booking", "task", and "order" in UI copy — they were used -interchangeably in the old build and meant nothing specific to a rider. - ---- - -## 3. The routing problem underneath - -A miler's day is one solved **Vehicle Routing Problem** instance. The hub admin -solves it; the rider executes it. He is explicitly *not* allowed to re-optimise — -he can't reorder stops — but he absolutely needs to see the **shape of the -solution he has been handed**, because every classic VRP constraint has a physical -consequence on the bike. - -This is the intellectual backbone of the Home screen redesign. Each constraint maps -to something concrete: - -| VRP concept | What it means to a miler | Where it appears | -|---|---|---| -| **Depot / round trip** | The route starts and ends at the same hub, so the return leg is real distance he has to ride | `hub → progress → hub` rail + `≈ km round trip` | -| **Capacity (CVRP)** | Total parcels and weight must fit in the box. Discovering an overload at stop 9 is too late | Parcels · weight kg | -| **Time windows (VRPTW)** | Each booking has a customer slot. Arriving after it closes is a failed stop | "*N* stops past their booked slot" / "close within 30 min" | -| **Route duration limit** | The shift is the hard cap on the route. Time left ÷ stops left is the pace he is actually racing | Shift countdown + "only *X* per stop left" | -| **Fixed sequence** | The admin's order is the solution; re-sorting it would break it | The list never reorders; skip-not-reorder | -| **Service time** | Each stop takes real minutes (photo, weight, payment, OTP) | Folded into the per-stop budget | -| **Cash on route** | Not a textbook VRP term, but he is carrying it and is accountable for it | ₹ to collect | - -**All of this is derived from data the app already has** — stop coordinates, -quantities, `expected_pickup_time`, the shift window in prefs. No new endpoint was -required. The computation lives in `lib/views/Dashboard/home/route_brief.dart` as -a pure, unit-tested `RouteBrief` value object. - -Two honesty constraints in that code: - -- Distance is **straight-line haversine**, so it under-reads real road distance. - It is always rendered with a `≈` and never presented as a promise. -- Accepted stops have left the Home list for the Bookings tab, but they are still - on the route — their parcels still need collecting, their kilometres still need - riding. `RouteBrief.from(extraStops: …)` pulls them back into the totals so the - brief never understates the day. - -Reference reading: [FarEye — Vehicle Routing -Problem](https://fareye.com/resources/blogs/vehicle-routing-problem-how-to-solve-it), -[ArcGIS Pro — Solve Vehicle Routing -Problem](https://pro.arcgis.com/en/pro-app/latest/tool-reference/ready-to-use/itemdesc-solvevehicleroutingproblem.htm). - ---- - -## 4. Architecture - -### Stack - -| Concern | Choice | -|---|---| -| Framework | Flutter (Dart SDK ^3.9.2) | -| State / DI / routing | **GetX** (`GetMaterialApp`, `Get.put`, `Get.offAll`) + a little `provider` | -| Responsive sizing | `flutter_screenutil`, design size **390 × 844** — `.w` `.h` `.sp` `.r` everywhere | -| Persistence | `shared_preferences` (session, duty state, shift window, last GPS fix, accepted stops) | -| Maps & nav | `google_maps_flutter`, `flutter_polyline_points`, `geolocator`, `geocoding` | -| Push | Firebase Core + Messaging + `flutter_local_notifications` | -| Live tracking | MQTT (`mqtt_client`) + `flutter_foreground_task` | -| Media | `image_picker` (proof photos), `minio` (uploads), `qr_flutter` (UPI QR) | -| Motion | `lottie`, `shimmer`, custom primitives in `app_widgets.dart` | - -### Directory map - -``` -lib/ -├── main.dart App bootstrap, Firebase, GetMaterialApp, lifecycle -├── helpers/ -│ ├── app_bootstrap.dart Decides the opening screen (replaced the splash) -│ ├── shift_end_alarm.dart Android AlarmManager via MethodChannel doormile/shift_end -│ └── http_overrides.dart -├── data/ -│ ├── api_config.dart THE backend switch + legacy/v1 adapter -│ ├── accepted_store.dart Locally persisted accepted stops -│ ├── assignment_lookup.dart bookingid → bookingassignmentid resolver -│ └── mock_bookings.dart Demo data (gated behind !useNewApi) -├── Models/ -│ └── stop_status.dart Canonical StopStatus enum + raw-string normaliser -├── controllers/ GetX controllers (auth, duty, pickups, summary, …) -├── providers/ HTTP layer, one per domain -├── background/ Foreground service, live tracking, background logs -├── utils/ Kalman filter, MQTT service, device info -└── views/ - ├── introscreens/ First-run intro - ├── onboardscreens/ Sign in, OTP, MPIN - ├── Dashboard/ - │ ├── home/ Home + route_brief.dart - │ ├── pickups/ The whole per-stop flow (see §7) - │ ├── summary/ Earnings - │ └── profile/ Account, support, rewards, FAQ - ├── helpers/constants/ Colors, fonts, spacing, theme - ├── helpers/widgets/ Shared kit: app_widgets.dart, miler_app_bar.dart - └── offline/ Full-screen offline state -``` - -### App entry - -`main.dart` initialises Firebase, registers permanent controllers -(`ProfileController`, `RiderLogController`, `PickupController`, `LogController`), -starts logging, checks whether the shift already ended while the app was closed, -and runs `GetMaterialApp` with `AppBootstrap` as `home`. - -**`AppBootstrap`** (`lib/helpers/app_bootstrap.dart`) decides the opening screen -and nothing else: - -- app version changed since last run → force re-verification (MPIN if the rider - was signed in, otherwise Sign In) -- signed in + on duty → dashboard -- signed in + off duty → the go-on-duty banner -- signed out, intro already seen → Sign In -- first ever launch → Introscreen - -This replaced a four-second animated splash screen. That splash held two jobs: a -brand animation and these routing rules. Only the rules were load-bearing. The -animation was pure cost for someone opening this app 30–50 times a shift, layered -on top of the native launch image that already covers cold start. The routing -moved across intact; the four seconds are gone. - -### Shell - -`BottomPage` (`lib/widget/Bottom_page.dart`) is a floating pill nav over an -`IndexedStack` — four tabs, state preserved, no page ever mounted twice: - -| Tab | Screen | Purpose | -|---|---|---| -| **Home** | `Homepage` | Greeting, duty toggle, route brief, new stops | -| **Bookings** | `MyPickups` | The active route — ordered stops, hub → progress → hub | -| **Earnings** | `Summary` | Pickup counts, success rate, reward points, distance | -| **Account** | `ProfilePage` | Rider details, support, settings | - -Tab switches run a single 280 ms fade + rise controller. Pushed pages use -`Transition.cupertino` at 300 ms globally. - -### Background and location - -Location is the app's most safety-critical data path, so it gets real treatment: - -- **`MilerKalmanFilter`** (`lib/utils/kalman_filter.dart`) — a 4-D Kalman filter - (`[lat, lng, v_lat, v_lng]`) smoothing raw GPS. Phone GPS on a moving bike is - noisy; unsmoothed fixes produce jumping breadcrumbs and inflated distances, - which matters because distance feeds rider payout. -- **`LiveTrackingService`** — position stream → Kalman → MQTT publish, with - battery level attached. -- **`foreground_service.dart`** — keeps logging alive with the screen off via - `flutter_foreground_task` in its own isolate. -- **`ShiftEndAlarm`** — Android `AlarmManager` through a `doormile/shift_end` - `MethodChannel`, so the shift-end break log fires **even if the app is killed**. - This is why "Shift not set" is treated as a genuine fault, not a cosmetic gap: - with no end time there is no alarm. - -### Duty state - -`DutyController` is the single source of truth for "is the rider online?". -Duty state used to be re-derived from raw prefs in a dozen places, and the server -`onduty` int is clobbered by a flaky rider-log sync — which auto-kicked riders -offline after a pickup. The controller fixes the priority in one place: - -1. `onduty == 1` → a fresh server shift is authoritative -2. explicit `online` flag → the rider's own toggle, which survives the flaky sync -3. `onduty == 0` → offline -4. otherwise → default online - -Similarly, `StopStatus` (`lib/Models/stop_status.dart`) normalises the backend's -free-form `orderstatus` string. It used to be compared as raw literals in ~90 -places with case-sensitivity landmines (`'active'` vs `'ACTIVE'`) and variant -spellings (`'picked'` / `'picked up'` / `'pickuped'`). One enum, one parser, and -every filter in the app now agrees. - ---- - -## 5. Design principles - -These are not aspirational. Each one was derived from a specific failure in the -old build and is enforced in the code today. - -### 1. The 30–50× rule - -The daily loop repeats 30–50 times a day. Any confusion or extra tap becomes real -frustration multiplied many times over. This is the lens for every trade-off: a -one-second delay is not one second, it is a minute a day; a redundant tap is not -one tap, it is fifty. - -*Consequence:* the splash animation was deleted. Slide gestures were replaced with -taps everywhere except money. - -### 2. One screen, one primary action - -The rider is standing at a gate with a parcel under one arm. There is exactly one -thing he wants to do next. That thing gets the biggest, highest-contrast control; -everything else recedes. - -*Consequence:* the confirm sheet's hidden "swipe to update status" became three -visible tap buttons — one primary (Picked up / Delivered), two secondary (Skip, -Cancel). The Home stop card was cut down to one identity and one primary action. - -### 3. Gestures are earned, not default - -A slide is a deliberate friction device. It belongs only where an accidental tap -would be expensive. Everything else is a tap. - -*Consequence:* slides remain only on **payment confirmation** and **going Off -Duty**. Start-pickup, arrived, and status confirm are all taps now. - -### 4. Alert only when actionable; stay silent when healthy - -If a coloured banner appears when nothing is wrong, riders learn to ignore all -coloured banners. Warnings must be rare enough to still mean something. - -*Consequence:* the route brief's risk lines render **only** when there is risk. A -healthy route is a short, calm card. "Shift set" is a quiet grey strip; "Shift not -set" is a full warning surface with a pulsing icon, the consequence spelled out, -and a Retry — because an alert with no way out is just noise. - -### 5. Never move things under the finger - -The Bookings list polls every few seconds. Re-sorting a list while a thumb is -descending on it is how riders tap the wrong stop. - -*Consequence:* polls don't reorder the visible list mid-interaction. `Reveal` -animations are keyed per logical item and play **once**, in `initState`, so a -3-second poll never replays them. - -### 6. Readable at arm's length, in sunlight, with gloves - -Minimum font sizes were raised (the old build went down to 7sp). Touch targets -have a 44 px floor. Contrast is checked against a white surface in daylight, not -against a designer's dark monitor. - -### 7. Colour carries meaning, never decoration - -See §6. Green means go. Maroon means brand or pickup. Blue means delivery. Amber -means warning or skip. Red means danger. A colour never appears for variety. - -### 8. Motion is feedback, not garnish - -Every animation answers a question: *did my tap register* (`PressScale`), *is this -new* (`Reveal`), *is something loading* (shimmer skeletons), *did I change tabs* -(the nav fade + rise). Nothing animates just to look expensive. - -### 9. Degrade gracefully, log loudly - -The backend has real gaps (§8). The app never blocks on a missing endpoint — it -falls back, logs `[API_GAP]`, and keeps the rider moving. A rider stuck at a gate -because a summary endpoint is missing is an unacceptable failure mode. - -### 10. Honest numbers - -If a number is an estimate, it is rendered as one. Straight-line distance shows -`≈`. Missing data renders `—`, never `0`. A metric that can't include accepted -stops doesn't get called "Stops left" until it does. - ---- - -## 6. The design system - -Reuse the tokens. Do not hardcode new shades or sizes. - -### Colour — `lib/views/helpers/constants/Colorconstants.dart` - -**Semantic roles** (agreed; use these names, not raw hex): - -| Role | Token | Value | -|---|---|---| -| Brand / pickup accent | `ColorConstants.primary` | `#960019` maroon | -| Go / success / positive completion | `ColorConstants.acceptGreen` | `#12B76A` | -| Delivery / info accent | (in `stop_type.dart`) | `#2563EB` blue | -| Danger / cancel | `ColorConstants.errorRed` | `#DC2626` | -| Warning / skip | `ColorConstants.warning` | `#B45309` amber | - -The green was consolidated from four competing values (`#16A34A`, `#34C759`, -`#2E7D32`, `#4CAF50`) that were scattered across Accept, Picked up, Delivered, -Confirm, payment dialogs, verify success, and the done screen. They are now one -token. A few `#007AFF` and `#BA1A1A` strays remain in nav and summary. - -The full palette is a Material-3 role set — `surface*`, `onSurface*`, `outline*`, -`primary`/`onPrimary`/`primaryContainer`, `error*`, `warning*`, `success*` — so -new surfaces have a correct token without inventing one. - -### Typography — `Font_constant.dart` - -**Manrope**, bundled as the OFL variable font (`assets/fonts/Manrope/Manrope-VF.ttf`). -It is the free, legal stand-in for **Uber Move**, which is proprietary and cannot be -shipped. Fallback is Plus Jakarta Sans. - -Every text style in the app routes through `FontConstants.fontFamily` — there are -no hardcoded family strings in `lib/`, so a single constant swaps the entire app's -typeface. Scale runs `displayLg 32` → `headlineMd 24` → `headlineSm 20` → -`bodyLg 18` → `bodyMd 16` → `labelBold 14` → `labelSm 12`. - -### Spacing, radius, shadow — `design_constants.dart` - -4 px base scale (`spacingXs 2` … `spacing5xl 40`), radius scale -(`radiusXs 2` … `radius2xl 20`, `radiusFull 999`), and five shadow levels -(`shadowXs` … `shadowXl`). - -### Shared components — `views/helpers/widgets/` - -| Component | File | Use | -|---|---|---| -| `AppCard` | `app_widgets.dart` | White rounded card with system shadow | -| `PrimaryButton` | `app_widgets.dart` | Solid maroon CTA | -| `Reveal` | `app_widgets.dart` | One-shot fade + rise; **keyed**, plays once | -| `staggerDelay(index)` | `app_widgets.dart` | Staggered list entrance | -| `PressScale` | `app_widgets.dart` | Press feedback for custom tappables | -| `SkeletonBone` / `MilerShimmer` / `skeletonCard()` / `SkeletonList` | `app_widgets.dart` | Shimmer loading, replaces bare spinners | -| `MilerAppBar` | `miler_app_bar.dart` | The one top bar — logo, title, subtitle, trailing, 60 px, hairline | -| `RouteBrief` / `RouteBriefCard` / `ShiftBanner` | `home/route_brief.dart` | VRP route summary + shift alert | -| `stopKindOf` / `StopKindUi` | `pickups/stop_type.dart` | Pickup vs delivery branch — accent, icon, labels, verbs | - -**Do not hand-roll an AppBar.** Use `MilerAppBar`. Home is the one exception — it -carries a richer dashboard header — and its logo and typography are kept aligned -to the shared bar by hand. - -### Auth screen pattern - -All onboarding screens follow one shape: **top-anchored** content (heading and -input in the upper third, never vertically centred — this fixed the "input box too -low" complaint), a large left-aligned heading (26sp, w700, `-0.6` tracking), a -one-line helper (14sp, height 1.45), and a **pinned footer** with a single -high-contrast CTA — solid maroon fill, white text, w700, radius 14, 56.h, disabled -at alpha .35. Structure: top Column → `Expanded(SingleChildScrollView)` → pinned -footer. Mirror this for any new auth screen. - ---- - -## 7. Screen by screen — what was designed and why - -### Home — `views/Dashboard/home/homepage.dart` - -The rider's staging area: who he is, whether he's on duty, what the day looks -like, and what's new. - -**Header.** The brand mark sits at 56 px on a bordered, brand-tinted tile. At its -old 46 px, flat on a white header, it read as a smudge rather than a logo — the -tile gives it an edge. Next to it, **"Hi, {name}"** is the heaviest type in the -header (17sp / w800, near-black), with `Miler · {city}` beneath at 11.5sp / w600 -secondary. The greeting is the anchor of the screen, so it gets the weight; the -location is context, so it recedes. On the right, the duty toggle. - -**Shift banner.** Two deliberately different states. *Set* is a quiet grey strip -with the window and time remaining — confirmation, not news. *Not set* is a -warning surface with a left accent bar, a pulsing icon, the consequence stated -plainly ("duty hours and the shift-end reminder are off"), and a **Retry**. Without -a shift window the app cannot close duty and cannot fire the shift-end alarm, so -this is an operational fault. Retry re-reads prefs and says so honestly when the -fix is off-app — there is no standalone endpoint to re-pull the shift, only the -login / MPIN profile sync. - -**Route brief.** The VRP card described in §3 — a `hub → progress → hub` rail -showing *X of Y stops done*, then stops left · parcels + weight · `≈ km round -trip` · ₹ to collect, then risk lines only when there is risk. - -**Stop cards.** Redesigned Uber-style: PICKUP/DELIVERY badge + order id + circular -select in the header, a hero name (16.5sp w700, `-0.3` tracking), pin + address, a -grey items chip, and labelled Call / Details buttons. Removed along the way: a -misleading two-dot route timeline (a single stop is not a route — now a compact -location badge), and a redundant second identity row. - -### Bookings / My Pickups — `views/Dashboard/pickups/pickups.dart` - -The active route: the ordered list of stops with a hub → progress → hub overview. -Staggered `Reveal` on entry, keyed per stop so the poll never replays it. - -### The per-stop flow — `views/Dashboard/pickups/` - -| File | Role | -|---|---| -| `card.dart` | The stop card in the route list | -| `map.dart` | Map preview, navigation launch, **and the owner of the success-screen transition** | -| `multi_map.dart` | Whole-route map with real step numbers | -| `map_btn.dart` | The map-view entry row on the stop card | -| `pip.dart` | Picture-in-picture info card while navigating | -| `pickup_verify.dart` | Proof capture — photo, weight, OTP, signature plumbing | -| `sheet.dart` | The confirm bottom sheet | -| `skip_sheet.dart` | Skip with reason | -| `payment_screen.dart` | Cash / UPI-QR collection | -| `done.dart` | Success screen + "move to next stop" | - -**Critical navigation architecture — do not regress this.** The confirm sheet -(`_PickupBottomSheet._handleConfirm`) must **not** navigate to `PickupsDone` -itself. Pushing a full page from inside a modal bottom sheet is unreliable — the -success screen silently never showed, and an `isCurrent` race then popped -everything. Instead the sheet **pops with an outcome map**: +| The job | collect at kitchens, then a round of prepaid drops | first-mile: collect from a customer, take it to the hub | +| Home node | one per kitchen, orders beneath | one per stop, `flat: true` | +| Load line | `5 orders · 5 bags` | `Stop 3` | +| Hand-over point | **pickup-complete** | **acceptance** | +| Ladder | accept → arrived → picked → deliver | accept → work it on the work tab | +| Work tab is called | Deliveries | Bookings | +| Cash at the door | never | when the booking carries an amount | +| Verification form | none | full shipment desk | +| Closing leg | none — he finishes at the last door | `RETURN · HUB` | + +### How the line is chosen + +`lib/data/service_profile.dart` resolves it: stored tenant name → stored tenant +id → the **JWT `tenantid` claim** → build flag → logistics. + +The claim read (`ApiConfig.tenantIdFromToken`) is the load-bearing one in +production: the deployed backend does not return `tenantid` in the verify-pin +body, only in the signed token. That is not a security decision and must not +become one — the signature is unverified client-side and every request is still +authorised server-side. + +**It always falls back to logistics.** Showing a delivery rider the wrong +dashboard is recoverable; dropping a live parcel rider into an app with no +pickup flow is not. + +### Capabilities, never line names + +Everything else in the app reads a **capability**, never a line: ```dart -{'outcome': 'completed'|'cancelled', 'isDelivery': bool, - 'bonusPoints': int, 'paymentMethod': String} -// skip pops `true`; dismiss pops `null` +if (profile.collectsCash) // not: if (line == milkMan) +if (profile.needsProofPhoto) +if (profile.handsOffAtCollection) ``` -The **caller** — `map.dart`'s `_startPickupNavigation`, which is a normal page -route — inspects the result and does -`Navigator.pushReplacement(context, PickupsDone(...))` from its own route context -(`map.dart:788`). It also removes the finished stop from the list first, so it -doesn't linger as "active" blocking the next stop. This is what makes "Move to -next stop" appear after picked up / delivered / cancelled. +That is why a new kind of client is a new `ServiceProfile` rather than a third +UI. `test/line_capability_test.dart` and `test/service_vocabulary_test.dart` +hold it. -Other flow decisions: no-payment pickups skip the payment-method list entirely -(the reason list only shows for cancel or pickup-with-collection). The signature -step was dropped for pickups — photo plus weight is enough proof. Test pre-fills -in `pickup_verify.dart` were removed. - -### Earnings — `views/Dashboard/summary/summary.dart` - -Pickup counts, success rate, reward points, distance ridden. The hero count is -wrapped in an `AnimatedSwitcher` (fade + scale) so switching period animates -rather than cutting. - -### Account — `views/Dashboard/profile/` - -Rider details, support tickets, FAQ, notifications, alert sound, saved addresses, -rewards, terms. Page-entrance `Reveal`; `PressScale` on the tile rows. - -### Offline — `views/offline/offline_page.dart` - -A full-screen state with a breathing halo, stacked over the app whenever -connectivity drops. Retry routes back through `AppBootstrap`, which re-decides -login vs. home from scratch. +> **`lib/xpress` is reference only.** A verbatim port of the old Xpress-rider +> app, reachable only via `--dart-define=TENANT=delivery`. It renders its own +> screens and does not go through anything in this document. --- -## 8. Backend integration and known gaps +# 3. The lifecycle -The app targets the **new v1 backend** — `https://api.doormile.com/api/v1`, -Bearer-token auth, `{success, data, message}` envelopes, camelCase — alongside the -legacy jupiter/workolik backend. +This is the part to read before touching any delivery UI. -**Strategy: adapter + env flag.** `lib/data/api_config.dart` is the one switch and -mapper: +``` + ASSIGNED ─accept─▶ ACCEPTED ─go─▶ ARRIVED ─pick up─▶ PICKED + └──────────────── HOME owns all of this ───────────┘ │ + pickup-complete │ + ▼ + consignment · Out_for_Delivery + │ + DELIVERIES owns from here ▼ + navigate → deliver → DELIVERED +``` -- `ApiConfig.useNewApi` — defaults **true**; override with - `--dart-define=USE_NEW_API=false` to go back to the old backend and mock data. -- `ApiConfig.toLegacyEnvelope()` re-wraps v1 responses into the legacy - `{status, details}` shape with legacy field names. -- Providers branch on the flag and call the new endpoints; **the ~90 UI files are - untouched.** +### The boundary -Two mock layers had to be disabled for live data: `getMockQueues()` / -`getMockBookingPageItems()` fallbacks (now gated behind `!useNewApi`), and the -auth controller, which used to fake a Demo Rider (userid 9999) and bypass the -server entirely. In live mode `verifyPinWithServer` now does a real -`POST /miler/verify-pin` and succeeds only on a server OK plus a stored token. +`lib/data/work_domain.dart` · `WorkBoundary` -**The pasted API doc was inaccurate** (verified live 2026-07-18). `verify-pin` -returns the login under **`user` / `user.profile`**, not `data`. The mapper reads -the real shape. Booking field names are still based on the unreliable doc — -**re-verify the `pickupFromBooking` mapping once a real booking is assigned.** +**One function decides which screen works an order**, so the two screens cannot +hold different opinions about the same row. It used to be answered twice — Home +from its local stores, Deliveries from a `where` clause inside its fetch — and +they disagreed exactly where it hurts: an accepted booking was claimed by both. +It stayed on Home to be collected *and* appeared on Deliveries as live work, +offering a map, an I'VE ARRIVED and a confirmation sheet for a collection the +rider had not made. -`AssignmentLookup` (`lib/data/assignment_lookup.dart`) exists because of one of -these mismatches: `POST /miler/assignments/{id}/accept` keys on -`bookingassignmentid`, but everything the app renders comes from -`GET /miler/bookings`, whose rows only carry `bookingid`. Different sequences, -different values. Sending a booking id made the backend answer 404 while the rider -saw success and the booking was never actually accepted. `GET /miler/assignments` -is the only place the pairing is exposed, so the app fetches it and keeps a -short-lived map. +```dart +WorkDomain domainOf(stop, {collectedIds, acceptedIds}) + → pickup | delivery | closed // exactly one, always +``` -### Known backend gaps +**Picked up is the boundary, and only the backend can move it.** Two sources, +both authoritative, neither of them "the rider accepted it": -The app degrades gracefully around all of these and logs `[API_GAP]`: +- the status on the row — `Picked_Up` / `Converted_To_Consignment` or beyond; +- the collected record, written by Home **only after `pickup-complete` returned + successful**. It exists because the queue is a poll behind: without it a stop + the rider just handed over jumps back to Home for a few seconds, which reads + as the confirm having failed. -- no skip / resume endpoint -- no booking-cancel endpoint -- **no per-stop `type` field** (pickup vs delivery) and no `step` ordering -- booking carries no `assignmentid` / `consignmentid` — accept, reject and deliver - assume they equal the booking id -- no COD amount on the booking -- no update-PIN or rider-count endpoints -- no bonus-summary endpoint (rewards are derived from `/miler/earnings`) +It deliberately does **not** consult the accepted store. That set says a decision +was made, not that goods changed hands. -### Release safety +Where the boundary sits is a property of the line, declared once as +`ServiceProfile.handoffAt` and read here rather than re-derived. -Demo and test toggles are tied to `kDebugMode` so they auto-disable in release -builds: `kBypassGeofenceForTesting` (`pickups_controller.dart`), `kDemoMixedRoute` -(`stop_type.dart`), and the summary weekly-kms mock fallback. +### Why Home's list is *not* filtered through it + +`WorkBoundary` answers *"which screen **works** this order?"*. Home answers a +different question: *"what is my day?"*. Those are not the same list, and +filtering Home through the boundary broke two things at once: + +- `pickupQueue` drops `WorkDomain.closed`, and Home's list is documented to keep + finished stops — the trip's progress ring needs them as its denominator. + Filtering them turned "3 of 8 done" into "3 of 3 done". +- On logistics the boundary is *acceptance*, so every accepted booking is + `WorkDomain.delivery`. Home still has to show them: they are the rider's run, + and the kitchen group expands to list exactly those orders. The filter emptied + the group and the dropdown stopped opening. + +The guarantee is about **actions**, not display. Deliveries lists only what +`deliveryQueue` admits, and Home's own rungs are gated by +`MilkRun.worksOnOwnScreen`. An order can be *visible* on Home as part of the day +while being *worked* on Deliveries — what it can never be is actionable in two +places. + +### The consignment id + +`deliver` and `skip` key on the **consignment**, which exists only after +`pickup-complete` converts the booking. They are different sequences: passing a +booking id gets a 404 while the rider is shown success. + +`pickup-complete` returns the new id and `rememberConsignmentId` files it +against the order. This was broken for a while: `updatePickedStatus` sent no +`orderid`, so the mapper fell back to the *booking* id while `closeDelivery` +read the map by **order** id. Every collected stop was filed under a key nothing +would ever ask for, and pressing **Delivered** answered *"this order was never +picked up on the system"* for a bag the rider was holding. + +The door now resolves it from four places in order of freshness: the row, the +local record (by order id, then booking id, for stops filed the old way), and +finally `GET /miler/assignments`. Four empties is a real state and gets a real +message — never a fabricated id. --- -## 9. What we are doing with this app now +# 4. The API -The programme is one thing: **make the app simple enough that a rider running it -30–50 times a day never has to think about it.** The app was already -well-built — branded app bars, a coherent maroon palette, a premium Earnings hero, -clean cards. It did not need a layout rewrite. It needed clarity, motion, and -honest data. +The rider surface only: `/miler/*`, 38 routes, base +`https://api.doormile.com/api/v1`. **The API reference document is the contract +authority** — the local `doormile_backend` checkout is behind production and +contradicts it on at least five points. -### Done +### Auth -- **Terminology and IA** — one noun set (Route / Stops / Hub). Home = new stops - and route start; Bookings = the active route. -- **Typeface** — Plus Jakarta Sans → **Manrope**, verified on device. -- **Semantic colour consolidation** — four greens → one `acceptGreen`; roles - documented and applied. -- **Slide fatigue killed** — start-pickup and arrived became taps; the confirm - sheet's hidden swipe became three visible buttons. Slides remain only on payment - and Off Duty. -- **Motion layer** — `Reveal`, `PressScale`, shimmer skeletons; staggered lists on - Bookings, Home and notifications; `Transition.cupertino` globally; animated tab - switches over a state-preserving `IndexedStack`. -- **Auth screens** — one top-anchored pattern with a pinned solid-maroon CTA; - logo shrunk 180 → 104.h so content sits higher. -- **Mixed pickup/delivery plumbing** — `stop_type.dart` gives one branch point; - the delivery flow is fully wired end to end (card badge, OTP + photo verify, - no-payment "Delivered", done screen) behind `kDemoMixedRoute`. -- **Duty and status normalisation** — `DutyController` and `StopStatus` ended the - "rider randomly goes offline" and "status string mismatch" classes of bug. -- **Splash screen deleted** — routing preserved in `AppBootstrap`, four seconds - per launch removed. -- **Home redesign** — bigger, framed logo; bold greeting; shift-not-set promoted - to a real alert with a Retry; and the **VRP route brief** (§3), covered by 15 - unit tests in `test/route_brief_test.dart`. +Two-step: phone → PIN. `configid` is **1001**, the partition riders live in. -### Next +``` +POST /miler/login { phone, configid } +POST /miler/verify-pin { phone, pin, configid, device_token } + → { success, token, user: { …, profile } } +``` -1. **Ship mixed routes for real.** Everything on the app side is built. It is - blocked on the backend sending a per-stop `type` (`pickup` | `delivery`) and a - `step` order. The moment it does, `stopKindOf` prefers it automatically and - `kDemoMixedRoute` becomes irrelevant. -2. **Re-verify `pickupFromBooking`** against a real assigned booking (§8). -3. **Retire the accept/reject metaphor on Home.** This is not an on-demand - marketplace — the route is pre-assigned, so multi-select checkboxes plus - "Select All" plus single-tap accept is three interaction models for a decision - the rider doesn't actually get to make. Reframe as *start assigned route*. -4. **Make skip/resume first-class** in the route view, and get a real backend - endpoint behind it. -5. **Close the remaining colour strays** (`#007AFF`, `#BA1A1A`) in nav and summary. -6. **Real road distance** in the route brief once a routing/matrix API is - available — the `≈` haversine is a deliberate placeholder. +No `data` key on verify-pin — the profile fields live under `user.profile`. +Bearer token on everything else; role 5 enforced. + +`POST /miler/reset-pin` needs an **admin** token and is deliberately absent from +`MilerApi`. It was once open, and reset-pin → verify-pin took over any rider +account given only a phone number. + +### The pickup flow + +| Step | Call | +|---|---| +| 1 | `POST /miler/bookings/:id/reached` | +| 2 | `POST /miler/bookings/:id/parcel` | +| 3 | `POST /miler/bookings/:id/payment` | +| 4 | `POST /miler/bookings/:id/pickup-complete` | + +**`pickup-complete` is the pivot.** It converts the booking into a consignment, +recomputes chargeable weight from the dimensions the rider measured, and decides +routing: matching 3-digit pickup/delivery pincode prefixes are hyperlocal and +the consignment goes **straight to `Out_for_Delivery`** in the rider's hands. +Everything else routes via a hub. + +### Delivery + +``` +POST /miler/consignments/:id/deliver { deliveredtoname, otp?, photourl, lat, lon } +POST /miler/consignments/:id/skip { reason, lat, lon } +``` + +The consignment must be `Out_for_Delivery` or both are a 400. `otp` is required +**only** when the tenant has `requiredeliveryotp` on — off by default, and off +for DailyGrubs. Do not send a placeholder. + +### Traps, all of them load-bearing + +- **`accept`/`reject` key on `bookingassignmentid`**, not the booking id. +- **`deliver`/`skip` key on the consignment id.** +- **`vehicle-required` and `reject` read query strings**, not a body. +- **Telemetry scalars are strings.** `latitude`, `speed`, `heading`, `battery` + on `/logs` and `/consignments/logs` fail to parse as JSON numbers. +- **Never send `userid` in a telemetry body.** It once let one rider write + another's GPS into the dispatch index. +- **The break status is `Break`**, not `On_Break`. +- **`POST /miler/deliveries/start` does not exist.** It was declared and called + after every pickup, and 404'd every time. There is no rider-facing release + step to make — `pickup-complete` already does it. Removed; do not add it back + without a contract change. +- **`PATCH /bookings/:id/addresses`** is also off-contract and still referenced. + +### Timestamps + +IST wall-clock in `timestamp without time zone` columns. Some responses come +back with a trailing `Z` anyway — a Go + pgx footgun where a naive timestamp +loads under the UTC location and marshals with a marker it never had. + +`DateTime.tryParse` believes the `Z`. That broke the Activity tracking rail in +the one way it cannot survive: an event stamped `11:43Z` sorted *below* two +11:57 events, because as an absolute instant it is 17:13 IST — while its own +rung still printed `11:43`, so nothing on screen explained the order. + +**Everything goes through `parseStamp`** (`activity_format.dart`), which strips +any trailing zone marker first. --- -## 10. Conventions for anyone touching this code +# 5. The data layer -1. **Read §7's navigation warning before touching the pickup flow.** The - sheet-pops-an-outcome architecture is load-bearing and was arrived at after a - silent-failure bug. -2. **Use the tokens.** `ColorConstants`, `DesignConstants`, `FontConstants`. No - new hex values, no hardcoded font families, no magic spacing. -3. **Use the shared components.** `MilerAppBar` for headers, `AppCard` for cards, - `PrimaryButton` for CTAs, `Reveal`/`PressScale`/skeletons for motion and - loading. Hand-rolling one of these is how the app got four different greens. -4. **Size with ScreenUtil.** `.w` `.h` `.sp` `.r` against the 390 × 844 design - size. Use `.w` (not `.sp`) for anything that must not resize with the user's - font setting — logos, icon frames. -5. **New gestures are taps.** A slide needs a justification: it is only for - actions that would be expensive to trigger by accident. -6. **Keep `Reveal` keyed.** An unkeyed `Reveal` in a polled list replays every few - seconds and looks broken. -7. **Never block the rider on a missing endpoint.** Fall back, log `[API_GAP]`, - keep him moving. -8. **Render honesty.** `≈` for estimates, `—` for missing, never a fake `0`. -9. **Run `flutter analyze lib/ test/` before you call it done.** The target is - zero errors; the existing infos are legacy noise, don't add to them. -10. **Test the pure logic.** `RouteBrief` is a value object with no Flutter - dependency precisely so it can be unit-tested. Follow that pattern for new - derived data. +``` +GET /miler/bookings + │ + WorkRepository one request however many screens ask; one copy; + │ out-of-order responses dropped + ApiConfig.pickupFromBooking new booking → legacy stop map + │ + ┌────┴────┐ + Home Deliveries both classify through WorkBoundary +``` + +### Local stores — `lib/data/accepted_store.dart` + +| Store | Means | Written | +|---|---|---| +| `accepted` | he took the work on | on accept | +| `collected` | it is in his hands | **after** `pickup-complete` succeeds | +| `outForDelivery` | he has set off | on the round-start press / opening a stop | +| `completed` | finished, with its clocks | at completion | +| `skipped` | put down, with the reason | on skip | +| `consignmentIds` | order id → consignment | at the pivot | + +Every one is a **mirror of a fact the app already established**, existing because +the queue endpoints are a poll behind the rider. None of them invents a server +state. The stores are merged *last* when Activity loads, and they win. + +### The event ledger — `lib/data/order_events.dart` + +`GET /miler/bookings` returns a booking's *current* status and no event feed, so +four of the six moments a rider's day is made of left no trace: he accepted at +9:10, reached the kitchen at 9:28, took the bag at 9:35, set off at 10:15, and +by the time he opened his own history all four were gone. + +`{orderid: {event: iso}}` in one prefs key, stamped **at the moment each is +true** by the screen that already knows: + +| Event | Stamped by | +|---|---| +| `assigned` | `WorkRepository`, first fetch carrying the booking — and **only for work not already past his hands**, or opening the app in the afternoon retro-labels the morning | +| `accepted` | Home, all three accept paths | +| `arrived_at_pickup` | Home's `_advanceStop('ARRIVED')` | +| `picked_up` | Home's `_advanceStop('PICKED')` | +| `out_for_delivery` | `MyPickups.startRound` | + +**Written once** (re-accepting does not move the clock), **cleared on resume** (a +resumed skip is a new attempt), **pruned at two days** so a shift crossing +midnight keeps its morning. **Nothing reads it to decide anything** — no gate, no +filter, no status. Delete the key and the app behaves identically; Activity just +draws fewer rungs. + +### Mocks + +`MockBackend` and `MealRunMock` are behind `--dart-define=MOCK_BACKEND`, off by +default, and only tests mutate the flag. `purgeDemoRecords()` runs at launch and +strips anything whose id starts `MOCK-`. **No mock data is reachable in a +production build.** --- -## The core idea, simply put +# 6. The design system -**A miler is handed a fixed, ordered route for a time slot, does every pickup and -delivery on it in sequence, and brings everything back to the hub he started -from — and this app guides him through every stop of that journey.** +> **Content leads. Containers recede.** -Everything else in this document is in service of making that loop cost him as -little attention as possible, fifty times a day. +Not a style preference — arithmetic. Home went through three versions and each +was killed by the same thing: + +| version | permanent furniture before the first stop | +|---|---| +| shift banner + CURRENT SHIFT card + 3 stat cards | **~351pt** | +| brief card + NEXT ACTION glass card | **~270pt** | +| brief line + run head | **~100pt** | + +At 351pt on a ~650pt scrollable region, over half the screen was spent before +the rider saw a single stop. Every element was individually defensible. **That is +the failure mode: a screen does not get heavy in one commit, it gets heavy a +line at a time.** + +### Typography + +**Poppins**, bundled, all seven static weights. + +> It had never rendered a glyph. `pubspec.yaml` declared the family as +> `poppins` with a lowercase path on every asset while the files are +> `assets/fonts/Poppins/Poppins-*.ttf`. Asset bundling is case-sensitive on both +> platforms, so nothing resolved and everything fell back to Roboto, silently. + +All seven weights are declared because Poppins is not a variable font. The app +leans on `w700`/`w800` for headings; without explicit entries Flutter +synthesises them by smearing SemiBold, which is what makes a heading look muddy +at exactly the size it is meant to be read from. + +**The weight scale is calibrated for this family.** Poppins is geometric — wide +bowls, thick stems — so its `w800` is far darker than a variable Manrope's. The +whole scale came across a grade too heavy on the swap and every weight in `lib/` +(outside `lib/xpress`) was stepped down one: 900→800, 800→700, 700→600, 600→500. +Relative hierarchy untouched. **If the family changes again, re-tune this** — a +weight is a number, not a design. + +`MilerType` is the scale. Nothing sets `fontFamily` by hand. + +**Poppins carries no U+2192 or U+2713.** The route line was `'$from → $to'` in a +single `Text`, so the app's own typeface drew the most-read line on the Activity +record as `Vidhya Kitchen ▯ Joe Mathew`. Marks are `Icon`s now, drawn from a font +that is always present; only *words* are text. `₹` **is** present — verified by +reading the cmap, which is far cheaper than a render. + +`fontFamilyFallback` is `['Manrope', 'Roboto', 'sans-serif']` — Manrope first +because it is bundled, so its coverage is guaranteed rather than depending on +what the platform ships. + +### Colour + +`ColorConstants` · brand red **#960019**. + +| Token | Means | +|---|---| +| `primary` | actions, and the live/current state | +| `primary` @ 0.10–0.12 | selection, and status tints | +| `acceptGreen` | done — and money, via `moneyGreen` | +| `warning` | attention: parked work, late, an exception | +| `secondaryText` | everything waiting on somebody else | +| `pureSurface` | the paper | + +**Colour is never the only channel.** Every state carries a word *and* a glyph +*and* a tint — these are read outdoors, in sunlight, on a scratched screen, by +somebody in a hurry. + +### Icons + +Material **Rounded**, one family, matching Poppins' geometry. Ten stragglers on +the sharp-cornered default set were moved; only `Icons.trip_origin` remains, as +it has no rounded variant. + +### Motion + +| Gesture | Motion | +|---|---| +| Expand / collapse | `AnimatedSize` 220 ms `easeOutCubic` | +| Chevron, chips, selection | `DesignConstants.motionState` (200 ms) | +| State swap | `SmoothSwap` | +| Page push | `openScreen` | + +Everything is quick and functional. Nothing is a reveal. + +### Rules that keep recurring + +- **`AnimatedSize`, never `AnimatedCrossFade`,** around a collapsing panel. The + crossfade lays out both children at the interpolated height, so the + full-height child is laid out inside a shrinking box — the + `BOTTOM OVERFLOWED BY n PIXELS` stripe. +- **`BottomPage.bottomInset(context)` is the only source** of the floating nav + bar's height. `SafeArea` knows nothing about it, and `bottomInset` already + includes `MediaQuery.padding.bottom` — wrapping something in both + double-counts the gesture inset. +- **Anything in a fixed column must be `Flexible` and ellipsised.** A 21-character + booking reference in a fixed 5/9 column overflowed by 12 px and painted the + debug stripe over a production screen. +- **A fixed-height box around text clips at 2.0× scale.** Size to the type. + +--- + +# 7. The bars + +### The material + +`milerGlassSurface()` — an 18-sigma `BackdropFilter` under `glassRed`, a +translucent maroon at 7%, passed as `flexibleSpace` so the wash covers the +status-bar inset too. A bar tinted only below the notch reads as a stripe rather +than a surface. + +A flat 7% red over white is a colour; glass is a *relationship*. Content scrolls +under these bars, so the blur has something real to work on. 7% is deliberately +weak — at that strength the surface stays light enough for near-black type, +which is what keeps a title readable through a windscreen mount in sun. + +Home's header is the exception: nothing scrolls behind it, so a blur has nothing +to work on. It uses `Color.alphaBlend(glassRed, pureSurface)` — same tokens, +composited flat, so the tint at the top of the screen cannot change as the rider +switches tab. + +### The top bar — `MilerAppBar` + +Solid `primary`, 76pt, title left, optional trailing control. Everything on it +inverts, including the status-bar glyphs. + +### The sheet + +`MilerSheet` rounds the page's top corners to 22 and **clips**; the scaffold's +background becomes `primary` so the brand shows through behind them. + +The bar and the page used to meet on a hard horizontal line the full width of +the phone, which made every tab read as *a coloured strip above a form* — the +bar bolted on rather than the top of the screen the content lives in. All four +tabs use it. It clips rather than merely decorating, because three of the four +hold a scrolling list and a `BoxDecoration` alone would let rows paint over the +corners on the first pixel of a scroll. + +> Home cannot set `backgroundColor: primary` (its `Stack` paints on that ground), +> so the brand is a `ColoredBox` directly behind the sheet. Without it the +> corners reveal the page's own colour and the curve is invisible. + +### The bottom bar — `BottomPage` + +A floating frosted pill, not a `bottomNavigationBar`. **0 Home · 1 +Bookings/Deliveries · 2 Activity · 3 Account.** + +| | | +|---|---| +| Fill | `pureSurface` at 82% under a 20-sigma blur | +| Radius | 32 at rest → 23 compact | +| Inset | 16 from each edge → 30 compact | +| Height | 66 → 46 | + +It is a `Positioned` pill inside the shell's `Stack` — which is exactly why +`bottomInset` exists. Tab 3 used to be Earnings; a pay figure is a +once-or-twice-a-day read and did not need a permanent quarter of the bar. + +--- + +# 8. Home + +`lib/views/Dashboard/home/` · tab 0 + +> **What do I do now?** + +Home owns the complete pickup lifecycle: **Accept → Navigate → Arrived → +Picked Up**. Nothing leaves for Deliveries until `pickup-complete` succeeds. + +``` +┌──────────────────────────────────────────────┐ +│ R DOORMILE DELIVERY [ on duty ] │ header (flat glass) +│ Hi, Rajan │ +╭──────────────────────────────────────────────╮ ← MilerSheet +│ Trip 1 Trip 2 Trip 3 │ trip selector +│ 91% done Not set Not set │ +│ │ +│ 2 stops left Today's route │ the brief +│ ≈2h · 9.8 km · 22 parcels · None │ +│ │ +│ TODAY'S RUN [ Map ] │ +│ │ +│ ① Vidhya Kitchen ➤ 3.1 km │ the pickup node +│ 5 orders · 5 bags ⏱ ~9 min │ +│ [ Accepted 5/5 ] [➤] [☏] [ⓘ] │ +│ ┌────────────────────────────────────┐ │ +│ │ Select all ⊙ │ │ +│ │ ① Joe Mathew Bag 1 › │ │ the steps +│ │ Gandhipuram │ │ +│ │ ② Arun Prakash Bag 2 › │ │ +│ │ RS Puram │ │ +│ └────────────────────────────────────┘ │ +│ ② Joe Kitchen ➤ 4.8 km │ +│ 3 orders · 3 bags ⏱ ~14 min │ +│ [ Pending ] [☏] [ⓘ] │ +╰──────────────────────────────────────────────╯ + ╭─────────────────────────────────╮ + │ 21 deliveries in hand › │ handoff pill + ╰─────────────────────────────────╯ +``` + +**No cards.** Not "small cards" or "flat cards" — none. Grouping is done by the +timeline's geometry, whitespace and type weight, because those cost nothing and +a container costs a border, a padding, a shadow and the width they take off the +narrowest axis the rider has. + +### The pickup node — the kitchen is the card + +`route_timeline.dart` · `PickupTimelineGroup` + +**The header is the place; the orders are its contents.** One kitchen, one node, +its orders disclosed underneath. + +Three responsibilities, three targets, none overlapping: + +| Gesture | Does | +|---|---| +| Tap the header | expand / collapse the place | +| Tap an order row | select / deselect it for the batch | +| The bag block on the right | open that order's detail sheet | + +The header's tap used to *select* on a flat group and *expand* on a grouped one, +so the same object did two different things depending on data the rider cannot +see — and on a kitchen the only way to reach the orders was a tap that, one +route later, would tick something. + +**A flat group** — one order at one address with no counter worth naming — has +nothing to disclose, so its header opens the detail sheet, which is the same +promise the chevron on it makes. A parcel route is always this. + +### The name must come from the domain + +> **The regression worth writing down.** +> +> Two functions answered "what is this place called" and read different keys: +> `MilkRun.sourceNameOf` took `sourcename`/`kitchenname`, `stopSourceName` took +> those **and** the CamelCase `SourceName`/`KitchenName` the payload sometimes +> carries. `trip_card.dart` uses both in one expression, so a booking with only +> the capitalised key was **not flat** (a counter was found → foldable group) +> but **titled "pickup"** (no counter → `navigationLabel` fell through to its leg +> description). +> +> On a device: a group headed by a backend word, which the rider taps and which +> opens a detail sheet instead of the list. Fixed at the domain layer — one +> reader, four keys, `stopSourceName` delegating to it. + +The rider thinks in **places**, not booking terminology. `Vidhya Kitchen`, never +`pickup`. + +### The steps + +**44pt** — one line of text plus its air, deliberately **lighter than the header +above it**. Five 15sp/w600 names in full slate under a 19sp/w800 header out-weigh +the destination they hang under, and the block reads as six peers rather than one +place with five bags in it. + +What a rider reads off a step is **who**, **where it is going**, and **which +bag** — the first two are how he decides whether Joe and Priya are the same trip +out of the door, the third is what he matches against a shelf. + +> The row used to carry the booking reference. That is what the office reads +> down a phone once, when something has gone wrong; the **drop area** is what he +> decides on every time he looks at the list. The reference moved to the detail +> sheet and the Activity record. `_areaOf` takes the first non-numeric component +> of the drop address — a door number is what he navigates by last. + +The bag sits at the right edge so a column of them lines up against a shelf he +is counting. + +### State, visible per group + +`Pending` → `Accepted 2/5` → `Accepted 5/5` → `Arrived` → `Picked up`. + +`onRungCount / groupSize` is derived, never stored. A rider reading "1 of 3" off +a place he knows holds four would not trust the number again. + +### Selection and the command bar + +He **ticks** the ones he wants and commits them from one floating bar — never a +per-card Accept, because six stops meant twelve buttons and six separate wire +calls, one mis-tap each. The select-all row at the head of a group takes the +whole kitchen in two taps. + +Declining is on the same bar, over the same ticked set: a rider ticking four +stops is declining them for one cause, and asking four times would make him pick +something, anything, four times. + +**A selection must never span two kitchens**, or he posts "arrived" for a shop he +is nowhere near. + +### The ladder + +``` + PENDING ──tick + Accept──▶ ACCEPTED ──navigate──▶ (at the counter) + │ + ▼ + [ Mark as Arrived · 5 ] ← floating bar + ▼ + ┌──────────────────────────────┐ + │ Arrived at Vidhya Kitchen │ ← bottom sheet + │ 5 orders · 5 bags │ + │ 1 Joe Bag 1 │ + │ … │ + │ ⟶ Slide to confirm arrival │ + └──────────────────────────────┘ + ▼ + ARRIVED ──collect the bags──▶ + [ Mark as Picked · 5 ] → same sheet, same slide + ▼ + PICKED ──▶ leaves Home for Deliveries +``` + +Three rules hold it together: + +- **He arrives at a place, not at an order.** The rung acts on a *set*. +- **The sheet lists what it covers.** A number cannot be checked against a shelf; + five named lines with a bag each can, and a missing sixth is visible before he + rides away. **Report missing bag** is where a short pick is recorded — marked + bags leave the route with their own record. Marking *every* bag kills the + slide: an empty crate is a call to the hub, not a state to file silently. +- **Pressing is easy, committing is deliberate.** The bar's button only *opens* + the confirmation; the slide inside the sheet is what writes. "Picked" is the + rider asserting he is holding somebody's lunch, and a resting thumb on a moving + bike must not be able to say that. + +### The handoff pill + +`AcceptedPill` — the count of work waiting on the other tab, derived from +`WorkBoundary.deliveryQueue` rather than from a stored list. A store written on +accept and cleaned on completion keeps anything that left the day some other way, +and the pill then counts work the rider no longer has. + +The verb follows the boundary: **"in hand"** on a round (the count is work +already collected), **"accepted"** on logistics (where the boundary *is* +acceptance). + +It uses `bottomInset` and **no `SafeArea`** — the inset already includes the +gesture bar, and a second opinion about it makes a floating control drift between +devices. + +--- + +# 9. Deliveries + +`lib/views/Dashboard/pickups/` · tab 1 · titled from +`ServiceProfile.workTabLabel` + +> **Which one now?** + +Only genuine post-`pickup-complete` work reaches this tab. It never shows pickup +actions or pickup sheets. + +``` + My Deliveries [ map ] +╭──────────────────────────────────────────────────╮ +│ Trip 1 0 of 21 done │ +│ ◎──1──2──3──4──5──6 ▒ │ route rail (scrolls, +│ │ edges fade — not clip) +│ On stop 1 of 21 · 1h 50m left │ +│ │ +│ NOW │ +│ ┌────────────────────────────────────────────┐ │ +│ │ SEQTEST Ukkadam [PICKED] │ │ +│ │ DailyGrubs RS Puram Kitchen │ │ +│ │ Bag 8 │ │ +│ │ ➤ 1.1 km · 3 min │ │ +│ │ [ Start Delivery ] [☏] [⋯] │ │ +│ └────────────────────────────────────────────┘ │ +│ UP NEXT │ +│ … │ +╰──────────────────────────────────────────────────╯ +│ 4 orders in hand │ +│ [ Start round ] │ release bar +``` + +### The queue + +`NOW` / `UP NEXT` / `LATER`, and `SKIPPED` above them all. One live card at a +time — while another stop is running there is no live card, because the one he +is working is on its own screen. + +Skipped stops keep their own heading **above** NOW: they are earlier in route +order, still resumable, and burying them hides work he has already been to once. + +### The card + +**The drop leads.** The destination is the job now; the source drops to a smaller +supporting block that still answers "whose bag is this?". The bag rides with the +customer's name — that is the line that stops Priya's lunch going to Joe. + +Bag identity is **stored at the counter, not recomputed**. Deliver Joe and a +position-derived Arun would silently become Bag 1, sending the rider looking for +a bag already in somebody's hallway. `saveBagLabels` fixes the pairing at the one +moment it is true. + +### Starting the round + +`startRound` is **local only**. It used to `POST /miler/deliveries/start` and +refuse to write anything unless that came back ok — the route does not exist and +404'd every time, so the bar could never succeed. + +`pickup-complete` already released the load. What is left is the one thing the +backend cannot know and the rider can: whether he has actually set off. That +drives the PICKED → ACTIVE word on the card and the rung on the record, and +claims nothing about the hub. + +> **Two buttons must not share a verb.** The card's said **Start Delivery** and +> the bar's said **Start delivery** — a thumb apart, doing different things. The +> bar is **Start round** and names the set it acts on. + +### At the door + +**arrive → verify → proof → payment → complete.** A geofence checks he is +actually there, the OTP or photo is taken where the rule demands it, and cash is +collected only on a line that takes cash. + +**A refused write moves nothing.** Completing locally after a call that never +landed is what made the app show a done screen while the office still had the +order live. The rider keeps the stop and is told why. + +--- + +# 10. Activity + +`lib/views/Dashboard/activity/activity_page.dart` · tab 2 + +> **Did that go through? What do I still owe?** + +``` + Activity +╭──────────────────────────────────────────────╮ +│ Your recent delivery activity │ +│ ( All ) Active Completed Cancelled │ +│ │ +│ 2/3 on time · 2/3 on route │ +│ │ +│ NEEDS A RETURN VISIT │ +│ ╭────────────────────────────────────────╮ │ +│ │ ╭──╮ Sri Balaji Stores │ │ +│ │ │↺ │ Skipped · 10:47 AM │ │ +│ │ ╰──╯ │ │ +│ │ ┃ Customer unreachable │ │ +│ │ [ Resume this stop ] │ │ +│ ╰────────────────────────────────────────╯ │ +│ TODAY │ +│ ╭────────────────────────────────────────╮ │ +│ │ ╭──╮ Joe Mathew │ │ +│ │ │✓ │ Delivered · 11:42 AM │ │ +│ │ ╰──╯ 🏪 Vidhya Kitchen │ │ +│ │ 4.6 km · 1h 27m #DM101 │ │ +│ ╰────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────╯ +``` + +### The card + +**The tile is the anchor** — 44pt, status-tinted, carrying the state's glyph. +It gives the eye a fixed left edge to run down at speed and carries the outcome +in shape and colour before a word is read. The status is then a *word* beside the +clock rather than a second badge — a tile and a pill saying the same thing is one +too many. + +**The name leads.** The card carried `ORDER ID` / `CUSTOMER` / `COLLECTED AT` as +eyebrows over their values — six lines for three facts — and the loudest was a +21-character routing reference. A rider does not know a stop by its booking id; +he knows it by who was at the door. The reference sits at caption size on the +right end of the measurements line — the card's one empty edge. It had a footer +band of its own once: a hairline, ~22pt of gaps, and a red bold `Details ›` +that **was not a control** — the whole card is the gesture and always was. The +loudest element on a finished stop was a link that could not be pressed, and +the band cost ~41pt on every row of a page whose entire job is scanning. + +### Filters + +Four slices, each with a real predicate over data the page already holds: + +| Chip | Predicate | +|---|---| +| All | everything | +| Active | `isSkipped` — parked work, the only rows carrying an action | +| Completed | finished and not cancelled | +| Cancelled | cancelled or declined | + +**Only the selected chip wears a container.** Four outlined chips are four boxes +competing to say which one is on. No counts — a number beside each word doubles +the text to answer what the list below answers by being that long. It scrolls and +is never a fixed-height box, which clips its own labels at 2.0×. + +### Grouping + +Parked work leads in `All` only — a promise to go back is the one row carrying an +action, and in date order a morning skip sinks below everything finished since. +Then `TODAY` / `YESTERDAY` / `AUG 18`, cut out of one sorted run. + +> Currently single-band in practice: both stores prune to today, so only the API +> path can return older rows. + +### The day line + +`2/3 on time · 2/3 on route · ₹1,240 collected`. One line, no tiles, no chart. +The only figure wearing a colour is the cash, because Miler is the carrier rather +than the retailer — that money is the rider's personal liability until he +reconciles it at the hub — and it is absent when there was none. + +--- + +# 11. Delivery details + +`lib/views/Dashboard/activity/delivery_details_page.dart` + +Pushed from an Activity card. **It reads; it does not fetch.** A screen that +re-fetched could disagree with the row that opened it, which is the one thing +history must never do. + +``` +← Delivery details + + ✓ Delivered + Vidhya Kitchen ⟶ Joe Mathew + 11:42 AM · Handed to Joe Mathew + + ┌──────────┬────────────┬────────┐ + │ DISTANCE │ TOTAL TIME │ ORDERS │ + │ 4.6 km │ 2h 32m │ 2 │ + └──────────┴────────────┴────────┘ + + ▌TRACKING + 9:02 AM 📍 Assigned 8m before you took it + 9:10 AM ☑ Accepted 18m to reach + 9:28 AM 🏪 Arrived 7m at the counter + 9:35 AM 📦 Picked up Collected from Vidhya Kitchen + 10:15 AM 🛵 Out for delivery 1h 22m on the road + 11:42 AM ✓ Delivered 5m at the door + + ▌ROUTE ▌DELIVERY INFO ▌EARNINGS ▌ORDER +``` + +### Why a page, not an expanding row + +The record used to open *inside* the list. An open record is several screens +tall, so the row under it left the viewport — and left what a lazy `ListView` had +built — and the list got harder to scan in exact proportion to how much it was +used. And the two jobs want opposite things: the list answers *did that go +through?* in one word per row; this answers *what happened on that one?*. + +### The timeline + +Six rungs, oldest at the top, outcome last and loudest — the direction every +order-tracking screen a rider has used reads. + +**A glyph per event, not one tick six times.** A rail of identical marks says +only "these all happened", which the rail says by being solid. A rider scanning +for the moment he took the bag is looking for a *shape*. + +**The clock is its own left column.** Right-aligned against the page edge, every +time sat at a different distance from the event it belonged to and the set was +unreadable as a set. In a column the journey can be read twice — once down the +events, once down the clock — and the gap between two rungs is visible without +arithmetic. + +**The note under a rung is the gap to the next one.** That is the part a rider +cannot work out by subtracting two clocks on a moving bike. + +> **Every rung is a real event.** A rung with no timestamp is **not drawn** — no +> placeholder, no "pending", no invented clock. A crate collected in one gesture +> genuinely has one event in its history. The single exception is the step a +> *skipped or cancelled* record never reached, drawn as an open ring with no +> clock, because there the absence is itself the fact. + +**The node sits on the title and the rail runs behind it**, in two halves, so the +line passes through rather than stopping either side of every disc. + +**No timeline package.** `timelines_plus` was a dependency and was removed: +`TimelineTile` centres its node over the whole tile and constrains its contents, +which put a dot level with nothing in the middle of an opened record and +overflowed the row by ~600 pt. + +### The route hangs each address on its own end + +`_route` used to read `pickupaddress` **only** and hang it under the *drop* +end. Right by construction on a parcel booking — one address, and the +collection point is the customer — but a milk-run payload carries both, so the +record printed the kitchen's street under the customer's name: the most +checkable fact on the one screen that exists to be checked, usually because the +office is asking. The reader now follows the same five-key precedence as +`_areaOf` in `route_timeline.dart` (`dropaddress` → `DropAddress` → +`deliveryaddress` → the pickup keys), because two readers answering "where did +this go" from different keys is the regression this codebase already had once +over the source *name*. With no distinct drop address the one address still +belongs to the door, exactly as before. Held by `delivery_details_test.dart`'s +`route` group. + +### Earnings, and the number that is not invented + +There is **no per-stop payout anywhere in this app**. `deliver` computes +`riderkms` and `ridercharges` server-side and returns neither; `/miler/earnings` +answers for a *period*. A figure here would be pay derived from a rate the app +does not hold — the one invention a rider would act on, argue about, and be wrong +about. The section shows the on-time bonus and the cash, else +`Stop payout · See Account`. + +--- + +# 12. Account + +`lib/views/Dashboard/profile/Profilepage.dart` · tab 3 + +> **Who am I to this app, what did I make, and how do I get help?** + +``` + Account 🔔 +╭──────────────────────────────────────────────╮ +│ ◯ Rajan A › │ +│ 9787698259 · Doormile Delivery │ +│ ┌────────────────────────────────────────┐ │ +│ │ 0 │ 19 │ 24/7 │ │ +│ │ Rewards │ Deliveries │ Help │ │ +│ └────────────────────────────────────────┘ │ +│ MONEY │ +│ 💳 Earnings › │ +│ Today, this week and this month │ +│ ACCOUNT │ +│ 👤 Edit Profile › │ +│ 📍 Saved Address › │ +│ 🔔 Alert Sound › │ +│ SUPPORT │ +│ 🎧 Help & Support › │ +╰──────────────────────────────────────────────╯ +``` + +**Section labels align to the row titles, not the icons.** Text-to-glyph +alignment is optical and never exact — an `Icon` does not paint flush to the left +of its box — so three headings sat ~8pt out of true, which is what makes a +settings page look assembled rather than laid out. The icons become a clean +margin rail. + +**The icon does not outrank the title.** Every glyph was drawn in the same +near-black as the words beside it, so a column of twelve read as loudly as the +twelve things they labelled. They are markers; the title leads. + +**The list clears the floating nav.** It reserved 28pt for a ~90pt bar, so the +last rows were permanently unreachable and read as text truncated mid-sentence. + +--- + +# 13. Bottom sheets + +A sheet is an **action surface**, not an information or navigation surface. If +tapping a thing opens a sheet that then needs another tap to act, the flow is +`screen → sheet → action` and that is too slow for field work. + +| Sheet | Opens from | One primary action | +|---|---|---| +| Stop detail | the bag block on an order row | Navigate | +| Arrived / Picked | the command bar | slide to confirm | +| Update status | the map screen | Delivered / Skip / Cancelled | +| Skip reason | the outcome | the reason, then confirm | +| Duty | the header switch | on / off | + +They share: a real blur (something *is* underneath), a title that says what is +being confirmed, the list of what it covers, and **one** obvious commit. A slide +where the write is consequential; a button where it is not. + +### The sheet kit (2026-08-20) + +`lib/views/helpers/widgets/miler_sheet_kit.dart`. An audit found **six +presentation styles** around one kind of surface: glass at 28; opaque white at +24 (stop detail, preview); default white at 16 with no `isScrollControlled` +(reject/cancel); an ad-hoc container with its own shadow (the dead trip-map +sheet); `Get.bottomSheet` flat 16 (auth errors); and a floating card with +margins (payment confirm). Plus a hand-rolled drag handle per file — usually +`borderSubtle`, which on frosted glass is close to invisible. + +One presenter and four primitives now: + +| | | +|---|---| +| `showMilerSheet()` | the only `showModalBottomSheet` config: transparent route, scroll-controlled, `kMilerSheetStyle` / `large:` for the tall entrance | +| `MilerSheetScaffold` | glass + handle + padding + **one inset rule**: bottom pays `max(keyboard, gesture bar)`, never the sum — the double-SafeArea CTA drift, closed structurally | +| `MilerSheetHandle` | 40×4, `borderStrong` | +| `MilerSheetHeader` | title, one-line subtitle, optional tinted badge | +| `MilerSheetChoiceRow` | one answer in a reason list — promoted from `sheet.dart`'s private `_ReasonRow` when Reject became its third caller; selection is a soft fill + tick, never a solid slab | + +Every live sheet goes through it: duty, rung confirm, stop detail, pickup +preview, skip, pickup confirm, map, reject/cancel, location prompt, reject +reason, product details, auth errors, payment confirm. + +What the same pass fixed while rendering (`sheet_shots_test.dart`, +`sheet_layout_test.dart` — 4 widths × 3 scales × 4 sheets): + +- **Stop detail was leg-blind**: `DELIVER TO` over the *pickup* address, the + map pinned to the counter, the distance measuring the ride back to it — on + the tab where every stop is a drop. Same family as the record's Route bug; + fixed the same way, with a leg-aware read and an in-sheet distance to the + leg's own coordinates. It also gained the status word and a Bag row (stored + pairing first, payload `baglabel` fallback). +- The rung sheet was the one sheet still on **Flutter's default entrance**. +- Rigid text rows overflowed at 2.0× (`_journey`, the detail distance line) — + now Flexible; the rung sheet scrolls when a 2.0× manifest outgrows the + screen; the slide control clamps its type at 1.4× because a fixed 52pt pill + cannot grow. +- The Activity `Details ›`-style fake affordances in sheets went the same way: + the location prompt's Close row, the product sheet's 32pt cancel icon and + the reject sheet's Close button are gone — the handle and the barrier are + the dismissal. +- `_showmapDetailsSheet` ("Trip Map", ~350 lines) deleted: zero callers, and + its job has belonged to the stop detail sheet since the timeline rework. + +--- + +# 14. States every screen must survive + +| State | Rule | +|---|---| +| **Loading** | a skeleton shaped like the page it stands in for, never a spinner over the screen. `SkeletonBone` paints in `pureSurface` and is invisible outside `MilerShimmer` — without the wrapper it renders as a blank white screen indistinguishable from a hung load | +| **Empty** | a caption, not a screen: one glyph, one headline, one line, one way out | +| **Empty filter** | a fact about the slice with a way back — never the day's empty state, which would tell the rider he had done nothing | +| **Error** | `ErrorRetry`, and only when there is no cached day. A stale day beats an error page | +| **Offline** | the banner, and every store keeps working. The app is designed to be right before the network agrees | +| **2.0× text** | nothing clips, nothing overflows. `text_scale_sweep_test.dart` | + +--- + +# 15. What holds it + +| Test | Holds | +|---|---| +| `lifecycle_boundary_test.dart` | Home/Deliveries disjointness on both lines; acceptance never crosses the boundary; only pickup-complete does; consignment-id storage; one reader for a place's name | +| `kitchen_group_test.dart` | every key spelling names and groups identically; "pickup" never surfaces as a name; the drop area replaces the reference; one-order-one-bag; partial acceptance; no overflow at 390/375/320 × 1.0/2.0 | +| `picked_not_active_test.dart` | `Out_for_Delivery` from the backend is the consignment's state, not the rider's | +| `start_delivery_test.dart` | collecting does not release the round; an unreleased order still belongs on Deliveries | +| `activity_page_test.dart` | the chrome, the four slices, one filled chip, the row hierarchy, the parked band, the shimmer wrapper | +| `delivery_details_test.dart` | the six rungs oldest-first; the absent-rung rule; no fabricated payout; capability gating | +| `activity_format_test.dart` | the false `Z`; no arrow character in the route string | +| `one_bag_per_order_test.dart` | nothing starts counting something other than orders | +| `text_scale_sweep_test.dart` | 2.0× on the screens that had no coverage | +| `home_shots_test.dart` | 15 golden references for Home's route block | + +`flutter test` · `flutter analyze lib` + +--- + +# 16. Known gaps + +**Functional** + +- `assets/images/cancel.png` is referenced from `lib/xpress` and does not exist — + `delivery_shell_smoke_test.dart` fails on it. +- `PATCH /miler/bookings/:id/addresses` is still called and is off-contract. +- The hub console renders its delivery badge from the **booking** status, whose + enum ends at `Converted_To_Consignment`. It can never show *active* or + *delivered* until it joins the consignment by `booking.consignmentid`. +- `ErrorRetry` is close to unreachable — `_safe()` swallows endpoint failures and + the stores swallow their own parse errors, so a broken load shows the empty + state. +- Compliance and the event ledger are computed from **local** stamps. A phone + with a wrong clock produces a wrong verdict, and nothing reconciles it. +- **`RouteMetricsHelper.metersToStop` reads only the pickup coordinates** + (`route_metrics.dart`), so on a delivery leg every distance and ETA fed + through it — the delivery card's "1.1 km · 3 min", the queue ordering hints — + measures the ride back to the kitchen the rider already left. The stop + detail sheet now computes its own leg-true distance; the cards still read + through `metersToStop`. Fixing it there touches every card, ETA and the + rail's time-left arithmetic, so it is recorded rather than slipped into a + UI pass. + +**Design, not yet done** + +- (empty — the bottom sheets went through the visual pass on 2026-08-20; see + §13's sheet kit.) + +**Design gaps closed 2026-08-20** (kept here because the old list claimed them) + +- ~~Deliveries: progress not state-aware~~ — the per-stop mapping is + `stopProgressFor` in `trip.dart` now: pure, line-aware through + `isWorkComplete`, and it recognises **both legs'** "physically on" rungs. + It used to test `isActive || arrived` — pickup vocabulary — on the one tab + that only ever shows the delivery leg, so the customer's door the rider was + standing at (`deliveryArrived`) drew grey and the scooter parked behind him. + Note the half of the old claim that was *wrong*: `0 of 21 done` while a stop + is merely picked is **correct** on a milk run — picked is the middle of the + morning, not done — and the rail has counted through the line-aware + predicate all along. +- ~~a 21-stop route overflows the rail~~ — stale since the nodes rework: the + rail scrolls and auto-centres the scooter. What was real was the viewport + edge slicing a node down its middle, which read as a clipping bug rather + than "more route this way"; the scrolling branch now dissolves at both edges + (`ShaderMask`, `dstIn`). A short route ends where it ends, unfaded. Worth + knowing: with the two framing nodes and the 22pt gap floor, only **four** + stops fit a 390pt phone unscrolled — the fade is nearly every real round. +- ~~Home reads as four widgets~~ — the brief was the last box standing: a + `pureSurface` fill in a `borderSubtle` outline on a `daylightSurface` page, + a four-percent step doing no separating. Dissolved; and its 14pt inner + padding — content inset *inside a box* — went with it, because it was + parking the brief's text at 42 while `TODAY'S RUN` sat at 32, inverting the + 28-summary / 32-route gutter scale. + +**Verification** + +- Home's use of `WorkBoundary` is verified by code inspection, not a widget test: + `_HomepageState` cannot be pumped (Get, Geolocator, live fetches). +- Nothing in this document has been verified on a physical device by the author + of the current changes — only by tests and rendered goldens. diff --git a/API_SPEC.md b/API_SPEC.md deleted file mode 100644 index 95fb47d..0000000 --- a/API_SPEC.md +++ /dev/null @@ -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 ` 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=` 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": "", - "userfcmtoken": "", - "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": "" -} -``` -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": "", - "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=&t=` - -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": "", - "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": "" // 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": "", // 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.* diff --git a/BOTTOM_SHEETS.md b/BOTTOM_SHEETS.md new file mode 100644 index 0000000..0fa436f --- /dev/null +++ b/BOTTOM_SHEETS.md @@ -0,0 +1,330 @@ +# Miler — Bottom Sheets + +Every modal surface that rises from the bottom of the Miler rider app: what it +is, when it appears, what it looks like, and the rules it must obey. + +**Scope.** `lib/` only. `lib/xpress` is the ported Xpress-rider lane and keeps +its own surfaces; it is not covered here and must not be used as a reference. + +--- + +## 1. The one presenter + +Every sheet in this app is opened through **`showMilerSheet()`** +(`lib/views/helpers/widgets/miler_sheet_kit.dart`). No screen calls +`showModalBottomSheet` and configures a route itself. + +```dart +showMilerSheet( + context, + builder: (ctx) => MilerSheetScaffold(child: …), + large: false, // slower entrance for sheets covering most of the screen + isDismissible: true, + enableDrag: true, +); +``` + +It fixes four things so no caller can get them wrong: + +| Setting | Value | Why | +|---|---|---| +| `backgroundColor` | `transparent` | The scaffold paints the glass. An opaque route background shows as a square white corner behind the rounded one. | +| `isScrollControlled` | `true`, always | A sheet that cannot grow past half height is a sheet whose text field the keyboard covers. | +| `sheetAnimationStyle` | `kMilerSheetStyle` / `kMilerLargeSheetStyle` | One entrance curve, two durations. | +| Shape | Owned by `milerGlassSheet` | No caller re-declares a radius. | + +### What this replaced + +An audit found **six** presentation styles for one kind of surface: frosted +glass at radius 28; opaque `pureSurface` at 24; default white at 16 with **no** +`isScrollControlled` (so the keyboard covered the reason field); an ad-hoc +`Container` with its own shadow and a 50pt top margin; `Get.bottomSheet` flat at +16; and a floating card with margins. Five of them drew their own drag handle — +four at 40×4 in `borderSubtle`, which on frosted glass is close to invisible — +and each applied `SafeArea` its own way. + +A rider crossing one shift met most of these. That is what makes an app feel +assembled rather than designed: the same gesture arriving on different-looking +surfaces. + +--- + +## 2. The structure inside + +**`MilerSheetScaffold`** owns the fabric, the handle, the content padding and +the bottom inset. A sheet author writes content and nothing else. + +``` +┌───────────────────────────────────┐ +│ ▬▬▬▬ │ MilerSheetHandle — 40×4, borderStrong +│ │ 10 top / 14 bottom margin +│ ◐ Title │ MilerSheetHeader — 18sp/w700 +│ One supporting line. │ subtitle 13.5sp/w600, badge tinted 21sp +│ │ +│ ───────────────────────────── │ content — the sheet's own +│ │ +│ [ Primary action ] │ +└───────────────────────────────────┘ + 20dp gutter, matching the page beneath +``` + +### The inset rule — one line, and it has bitten this app before + +```dart +bottomInset = max(viewInsets.bottom, viewPadding.bottom) +``` + +The keyboard **replaces** the gesture bar; whichever is up pays the inset. +**Summing them is the double-SafeArea bug** — it floats the CTA a gesture bar's +height above the keyboard. Never add a `SafeArea` inside a sheet. + +The padding is animated (`DesignConstants.motionState`, `easeOutCubic`) because +a sheet that jumps its full keyboard height in one frame reads as a glitch +rather than a response. + +### The handle + +`borderStrong`, **not** `borderSubtle`. At 7% glass over a dimmed page, subtle +grey measures under 1.15:1 against its own ground — so the sheets most worth +flicking away looked least like they could be. + +Hide it (`handle: false`) only for a sheet that must not be casually dismissed. + +### The header badge + +Optional, **tinted, never filled** — a filled disc at this size outweighs the +title it introduces. Pass the accent the subject already wears elsewhere (duty +green, brand red, a stop-kind accent) so the sheet is visibly about the thing +that opened it. + +### `MilerSheetChoiceRow` + +The standard row for a list of answers: label, optional icon, accent, selected +state, optional rule beneath. Label 14.5sp. Used wherever a sheet asks the rider +to pick one of several things. + +--- + +## 3. The sheets + +### Home + +#### Duty — `duty_sheet.dart` +**Opens:** tapping the duty control in the app bar. +**Asks:** `Go on duty?` / `Go off duty?` +**Body:** one sentence of consequence — on duty, the hub can add you to the next +slot and live tracking runs; off duty, the hub stops assigning and tracking +pauses, and any booking you are holding has to be finished first. +**Commits with:** a button. + +> **Why not a slide.** This had accumulated a handle, an icon disc, a title, a +> subtitle, a *drawn diagram of the switch moving*, three icon-and-text +> consequence rows and a slide-to-confirm — eight blocks for a question with two +> answers. The slide was borrowed from the payment flows, and this app's rule is +> that **slides are reserved for money and for custody**. Ending a shift is +> neither. + +#### Stop detail — `stop_detail_sheet.dart` +**Opens:** tapping a stop on the trip card, or a queue row on Deliveries. +**Type:** `DraggableScrollableSheet` — `0.78` initial, `0.5` min, `0.95` max. +**Answers:** *where actually is this* (a small non-interactive map with the hub, +the stop and the leg between them) and *what exactly is here* (full address, +contact, parcel counts, cash, notes, OTP). + +**Layout:** number chip + customer + status → map → `620 m away · 1 min ride` → +`DELIVER TO` + address → `WHAT TO DO HERE` + the task and its requirement → +a two-up **fact grid** (From / Bag / Order / Contact) on one white surface cut +by hairlines → Call and Navigate. + +> The fact grid was a `label ── value` table with a fixed 110pt label column. The +> values — the half a rider needs — sat in a ragged right column and a long one +> wrapped under a wide empty label, putting a hole in the middle of the block. +> Stacked two to a row, the whole set is scanned in a couple of saccades. + +This sheet is **why the card above it can afford to truncate**. The list is for +scanning; everything it drops lives here, one tap away. + +#### Pickup preview — `pickup_preview_sheet.dart` +**Opens:** the `i` / details control on a kitchen, and the kitchen card's own tap. +**Answers:** *where am I going, and what am I collecting there* — the leg drawn +from where the rider is standing, plus the load waiting at the counter. +**Primary action:** `Start navigation` — and **only then** does Miler hand off to +Google Maps. + +> Handing off means the rider leaves Miler: his route, bag counts and manifest +> all go behind another app. Committing to that on the strength of a kitchen's +> name and a straight-line distance is one tap too few. + +#### Rung sheet — `stop_action_sheet.dart` +**Opens:** advancing a stop from Home. +**Asks:** `Arrived at Vidhya Kitchen` / the collection equivalent, with the count +of orders at that counter and the **named manifest**. +**Commits with:** `Slide to confirm arrival` / `Slide to confirm pickup`. + +> Arriving and collecting are claims about the physical world — *I am at this +> counter*, *this bag is in my box*. They are written to the hub, they move food +> and money, and a stray thumb on a moving bike must not be able to make them. + +#### Reject reasons — `homepage.dart` +**Opens:** rejecting one or more assigned stops. +**Asks:** `Why are you rejecting this stop?` (or `…these N stops?`) +**Subtitle:** *The hub needs a reason to re-route it to someone else.* +**Body:** a `MilerSheetChoiceRow` list — too far from my route; shop/customer +closed today; parcel too large for my box; not enough time left in my shift; +address looks wrong. + +#### Turn on location — `homepage.dart` +**Opens:** when a permission or a fix is missing. +**Asks:** `Turn on location` +**Subtitle:** *Bookings and live tracking need your position. Nothing is assigned +to a rider the hub cannot see.* + +#### Product details — `homepage.dart` +**Opens:** from an order row. +**Title:** `Product details`, with an inventory badge. +**Body:** a plain ruled list of whatever the payload actually carries. An empty +payload is **one quiet sentence**, not a padded red box. + +--- + +### Deliveries and the live map + +#### Live stop sheet — `map.dart` +**Type:** `DraggableScrollableSheet`, and the **only** persistent (non-modal) +sheet in the app. Three snap points, no more: + +| Name | Extent | Purpose | +|---|---|---| +| Glance | `0.35` | Map-dominant; ETA still readable | +| **Working** | **`0.45`** | Default. ETA, identity, address and the action, without scrolling | +| Detail | `0.75` | Everything, map still legible | + +**Hierarchy — how long → where → who → what action:** + +``` + ▬▬▬▬ + 6 min ← the dominant operational figure + 1.8 km away · arrive ~10:42 + ───────────────────────── ← one hairline, the sheet's only rule + Joe Mathew ☎ ← identity + Call + 12 SNS Colony, Peelamedu + Bag 8 + [ Navigate ] [ I've arrived ] pickup leg + ⟶ Slide to finish stop delivery leg +``` + +One continuous surface. **No cards inside it** — the hierarchy is typography, +whitespace and that single hairline. + +> The camera's bottom padding is derived from `_sheetWorking` and the real safe +> areas, not from a remembered pixel value. A hardcoded `300.h` agreed with 45% +> only on the device it was measured on: on a 320×568 phone it framed the route +> into a sliver, and at large text the stop pin ended up under the sheet. + +**Call** uses `StopContact.forLeg()` — the kitchen's number on a pickup leg, the +customer's on a delivery leg, and a label that says which. It is omitted +entirely when there is no number rather than offered dead. + +#### How did this stop end? — `map.dart` +**Opens:** *only* after the rider completes the slide on the live sheet. **The +slide itself commits nothing.** +**Title:** `How did this stop end?` +**Subtitle:** *This is recorded with the office straight away.* +**Choices — deliberately unequal:** + +| Choice | Weight | +|---|---| +| **Delivered** | Filled primary — the forward/success path | +| Skipped | Warning/amber — an exception | +| Cancelled | Destructive | + +The slider carries `Semantics(button, label: 'Finish this stop', hint: 'Slide, +or double tap, to choose how the stop ended', onTap: …)` — a drag-only control +is unusable with a screen reader, and with gloves on a cold morning. The +accessible path opens the **same** question, so deliberateness is preserved. + +#### Skip — `skip_sheet.dart` +**Opens:** choosing Skip. +**Body:** the reason list — customer unreachable; customer not at the location; +wrong address; access problem; customer refused; delivery paused; quantity +issue; other. Renders through `MilerSheetChoiceRow`. +**Guard:** at the limit it shows `Skip Limit Reached` — *You have exceeded the +limit of 2 skips within 3 hours.* + +#### Stop brief — `sheet.dart` (`_StopBrief`) +Not a sheet itself: the shared **identity block** used by the map sheet, Update +Status and Skip, so all three name the stop the same way. Before it, the skip +sheet asked "Skip this pickup" without ever saying *which* pickup. + +--- + +### Bookings / orders + +#### Reject booking — `orderstaus_button.dart` +**Title:** `Reject booking` · **Subtitle:** *The hub re-routes it once it knows why.* + +#### Cancel this booking? — `orderstaus_button.dart` +**Title:** `Cancel this booking?` · **Subtitle:** *It returns to pending and the +hub reassigns it.* + +#### Payment confirm — `collect_payment.dart` +A confirm sheet before money is taken. **Slide**, per the money rule. + +--- + +## 4. Rules for a new sheet + +1. **Open it with `showMilerSheet`.** Never configure `showModalBottomSheet` + yourself. +2. **Wrap the content in `MilerSheetScaffold`.** It is the handle, the gutter and + the inset. +3. **No `SafeArea` inside a sheet.** The scaffold already paid the inset. Adding + one is the double-inset bug. +4. **No card inside a sheet.** The sheet *is* the surface. A bordered white panel + on frosted white is a line the rider reads past to reach what he came for. +5. **One header idiom** — `MilerSheetHeader`, badge tinted not filled. +6. **No close button.** The handle and the barrier dismiss. An `X` duplicates + both, and this app had exactly one sheet with one. +7. **Slide is reserved** for custody and money — arrival, collection, delivery + outcome, payment. Everything else commits with a button. +8. **A drag-only control needs `Semantics` with an `onTap` alternative** that + reaches the same confirmation. +9. **Unequal choices look unequal.** Forward path filled, exception amber, + destructive distinct. Never three identical buttons. +10. **Absent, not empty.** A row with no value is not drawn. A group with nothing + in it is not offered. + +--- + +## 5. Known gaps + +**Every modal sheet in `lib/` now goes through `showMilerSheet` + +`MilerSheetScaffold`.** The four direct callers listed in the first version of +this document have been migrated: + +| File | Was | Now | +|---|---|---| +| `skip_sheet.dart` | own `SafeArea` **wrapping** a hand-rolled glass + gutter + handle, plus a ✕ | kit presenter + scaffold + `MilerSheetHeader` | +| `collect_payment.dart` | right route flags written by hand | kit presenter (content already used the scaffold) | +| `multi_map.dart` | own radius 20, **no glass**, `SafeArea` **plus** `viewInsets` padding, 24 gutter, 22sp red title, ✕ | kit presenter + scaffold + header | +| `map.dart` `_PickupBottomSheet` | a `shape: …circular(24)` that clipped nothing behind a transparent route | kit presenter | + +### What remains + +- **`auth.dart` uses `Get.bottomSheet`** for its eight error notices. It is a + controller with no `BuildContext`, which is why it was left; migrating it + means giving the controller a navigator key or moving the notices to the + screens. Until then, auth errors are the one surface that does not speak the + system's language. +- **The empty-state artwork cannot be verified in a test.** `Image.asset` + resolves against a bundle the test binding does not carry, so no finder sees + it. The Activity empty state is asserted by its remaining control instead; + the picture itself needs a device check. +- **The live map sheet has not been visually QA'd** across the device and + text-scale matrix. `_PickupMapScreen` is private and needs Get, + SharedPreferences, Geolocator and a live map to pump; that harness does not + exist yet. +- **`milerGlassSheet`'s blur has not been profiled** on a mid-range Android with + the route animating. Readability and GPU cost outrank glassmorphism on an + outdoor screen; if it measures badly, a high-opacity surface is the answer. + **No profiling was run, so no blur change was made.** diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md new file mode 100644 index 0000000..1776475 --- /dev/null +++ b/DESIGN_SYSTEM.md @@ -0,0 +1,202 @@ +# Miler — Surface & Elevation System + +Why the app looked flat, what replaced it, and the rules that keep it fixed. + +**Scope.** `lib/` only. `lib/xpress` is the ported Xpress-rider lane and is not +part of this system. + +--- + +## 1. The diagnosis, with numbers + +The app did not look flat because it lacked decoration. It looked flat because +**the ground did nothing**, and every screen compensated privately. + +Measured against the page ground: + +| Token | Hex | Contrast vs canvas | Verdict | +|---|---|---|---| +| `neutralLight` | `#F2F4F8` | **1.021 : 1** | indistinguishable | +| `cardSurface` | `#EDEFF4` | **1.023 : 1** | indistinguishable | +| `surface` | `#FCF9F8` | **1.073 : 1** | invisible, and *warm* unlike everything else | +| `pureSurface` | `#FFFFFF` | **1.124 : 1** | the one that mattered — and too close | + +A tonal difference does not begin to read as a *different surface* until roughly +**1.25 : 1**, and on the budget LCD this app ships to it needs the top of that +band. So a white card on a white page was not a card; it was a rectangle you had +to be told about. Every screen that wanted its content to separate therefore grew +its own border or its own shadow — and once one screen does that, they all do, +each in its own way. That is the mechanism that turns one product into a +collection of independently-built Flutter screens. + +Five near-identical light tokens were also five names for about two decisions. + +### The second half of the diagnosis + +`MilerSheet` — the shared body under Home, Deliveries and Activity — painted +**white**. The `daylightSurface` canvas token existed but only two pages used it. +So the app's three main screens were a white page carrying white surfaces: a +1.0 : 1 step, which is not a step at all. + +--- + +## 2. The fix: four layers + +``` + 0 canvas the page itself. Never white. + 1 working a region the rider reads or works in. White, no border. + 2 raised something that acts. Same fill, plus lift. + 3 floating over everything, with a scrim: sheets, dialogs. +``` + +Declared once in `lib/views/helpers/constants/miler_surface.dart` as +`MilerSurface`. Read that ladder when choosing a ground — not the raw colour +constants, which is what let five of them drift into meaning the same thing. + +### Canvas — `#EEF2F7` → `#DEE3EA` + +White now separates at **1.290 : 1** instead of 1.124. Surfaces separate on +their own, so the borders and shadows screens grew to compensate can come off +rather than being piled higher. + +Nothing was traded for it: body text still clears **13.8 : 1**, and every +operational ink clears AA body on both grounds (see §4). + +### Which screen stands on which rung + +The rung is chosen by **what the screen actually draws**, not by preference: + +| Screen | Ground | Because | +|---|---|---| +| Home | working (white) | rows on a spine — **no cards by design** | +| Activity | working (white) | same — a ruled record, not a stack of objects | +| Deliveries | **canvas** | its content genuinely *is* raised objects: the NOW card is a layer-2 command surface and needs a ground to sit on | +| Record / details | canvas | one white sheet plus folded groups | +| All modal sheets | floating | scrim + glass | + +> **The trap, recorded because it was walked into.** Painting Home with the +> canvas produced a uniformly grey screen — it has nothing white on it to +> separate *from*, so the same flatness came back in a different colour. A +> darker ground only helps where something white actually stands on it. + +--- + +## 3. Elevation + +| Layer | Fill | Lift | +|---|---|---| +| working | `pureSurface` | none — the canvas step does the work | +| raised | `pureSurface` | `MilerSurface.raisedShadow` (`shadowSm`) | +| floating | `glassCard` | `MilerSurface.floatingShadow` + **scrim** | + +`MilerSurface.scrim` (`0x66000000`) replaced Flutter's default `black54` on +every sheet. **The scrim is the separation; the shadow is not.** A sheet that +needs a heavier shadow to be legible is a sheet whose scrim is too weak — the +scrim is the knob. + +### The one nesting the system allows + +`MilerSurface.inset` — a quiet tonal block *inside* layer 1, for a fact grid or +a folded group. Only when the block is a different **kind** of thing from what +surrounds it. **Never for grouping**: grouping is whitespace, type and the +timeline, which is why a route reads as a route without a box around each stop. + +--- + +## 4. Colour corrections that fell out of the canvas move + +Two state inks landed in "AA large only" on the deeper ground and were darkened +by ~2% — the hue is indistinguishable at this distance, the arithmetic is not: + +| Token | Was | Now | On canvas | On white | +|---|---|---|---|---| +| `acceptGreen` | `#047857` | `#047354` | 4.25 → **4.54** | 5.86 | +| `warning` | `#AE5008` | `#A44B08` | 4.12 → **4.54** | 5.85 | + +Every operational ink now clears **AA body (4.5:1)** on both grounds. Pinned in +`test/surface_system_test.dart` — a contrast rule that lives only in a comment +is a rule the next hex nudge undoes. + +### What the colours mean + +| Colour | Means | +|---|---| +| Brand red `#960019` | identity, navigation, primary action — **not** error | +| Green | confirmed success, completion | +| Amber | genuine attention/exception only — **never** normal pending | +| Error red | failure, and semantically distinct from brand red | +| Neutral secondary | pending, waiting, metadata | + +> Pending was amber once. On a fresh morning every kitchen is pending, so the +> screen opened in warning and left nothing to say with when a stop actually +> went wrong. + +--- + +## 5. Rules + +1. **Choose a ground from the ladder**, not from the colour constants. +2. **A container is justified when it is a layer** — a modal sheet, a floating + command bar, a grouped operational workspace. Not because the content inside + it is a group. +3. **No card inside a card**, and no white card on a white surface. +4. **If a surface needs a border to be seen, the ground is wrong** — fix the + ground, not the surface. +5. **Never introduce a one-off colour, radius, shadow or spacing number in a + page.** Five near-duplicate tokens is how this started. +6. **Measure.** Any new surface pair needs ≥1.25 : 1 to read as two surfaces, + and any ink needs ≥4.5 : 1 on the darkest ground it can land on. + +--- + +## 6. The second pass: the ladder made visible + +The foundation pass fixed the ground; a device screenshot showed the result +still *read* flat — the silhouette of every page was unchanged. The second +pass gave the system its visible vocabulary, applied identically on Home, +Deliveries, Activity, Account and the sheets: + +- **Hero heads.** A screen's key figure is a numeral (28sp w800, tabular), + captioned small — never a sentence in body type. Home: `15 · stops left` + with a 6pt determinate progress bar (canvas track, brand fill, green when + done) and `9 of 24 done · ≈4h · 38.9 km` under it. Activity: `4 · delivered + today`. Figures clamp at 1.3× text scale; captions ellipsise, numerals never. +- **Wells.** Anything that opens — Home's kitchens, Activity's trips — opens + into a canvas-toned inset (`radiusLg`, small padding). The well is the + ladder's one permitted nesting, and it is the *canvas* tone because that is + the only grey that stands on white (1.290:1). +- **Tags.** A label a rider matches against the physical world (`Bag 3`) is a + small filled tag: white on a well or tinted panel, canvas on white, brand + 10% when selected, error 8% for `Missing`. Same shape everywhere — Home + rows, the pickup sheet, the confirm sheet, the Deliveries queue. +- **Placeholders recede.** An unassigned trip tab drops to `disabledFill` and + w600 and says `None yet`; empty slots never compete with real work. +- **Account joined the ladder**: canvas ground, the identity + day-figures as + one panel, each settings group a panel, labels on the canvas between them — + scoped to the Account tab so the eight other `settings_ui` screens are + untouched. +- **`neutralLight` is not a fill on white.** It measures 1.02:1 there; every + such use found on these pages now takes the canvas tone. The token keeps + its other jobs. + +Pinned by: `home_gutter_test` (hero and route share an edge), +`stop_row_alignment_test` (the well's indent is bounded), +`home_structure_test` (one well, tags under 120pt, no borders or shadows in a +group), `activity_list_test` (a sheet is `working` or `canvas`, never the +warm off-white). + +## 7. Known gaps + +This was a foundation pass. It is not a completed product-wide redesign. + +- **`GlassCard` still carries three separation mechanisms** — fill, border and + shadow. Its rim is documented as a fix for shadows crushing on a budget LCD, + reported twice from a device, so it was left alone rather than changed on a + guess. It should be revisited *on a device* now that the ground works. +- **No typography or spacing consolidation.** Arbitrary `.sp` values still exist + across pages; `MilerType` was not rationalised into a smaller scale. +- **Icon families are still mixed.** Lucide was adopted for Activity and the + status vocabulary; Material rounded remains widespread elsewhere. +- **Account/Profile was not audited or migrated.** +- **No device or text-scale QA** of the new ground, and **no GPU profiling** of + `milerGlassSheet`'s blur — so no blur change was made. diff --git a/MILER_API_REQUIREMENTS.md b/MILER_API_REQUIREMENTS.md new file mode 100644 index 0000000..d0534ab --- /dev/null +++ b/MILER_API_REQUIREMENTS.md @@ -0,0 +1,789 @@ +# Miler App — API requirements for the backend team + +**From:** Miler rider-app engineering +**Date:** 21 Aug 2026 +**Against:** *Doormile Miler App — API reference* (38 `/miler/*` routes) +**Base URL:** `https://api.doormile.com/api/v1` + +Everything below was traced in the shipped app and, where it says *verified*, +confirmed with live calls against production using rider **Rajan A** +(`userid 38`, `tenantid 13`, configid 1001) on 20–21 Aug 2026. + +Requests are ordered by **what is blocking riders today**. Each one states the +symptom, the evidence, and the smallest change that fixes it. + +--- + +## 0. Status — updated 21 Aug 2026 + +The backend team shipped requests **1, 2, 3, 5, 6** and cross-cutting items +**10.1–10.4** the same day. The app has been updated against the new contract +and the whole suite is green; each section below carries its own status line. + +**What the app now does with it** + +| Shipped | What the app changed | +|---|---| +| `Collected_By_Miler` | A real rung in `ConsignmentState`, excluded from the hub guard — a rider is never told the hub is holding a parcel that is in his own box. | +| `POST /consignments/:id/start-delivery` | **Start round** is a server write again. The local released-set is written **only** for what the server released; a stop it refused keeps its PICKED word rather than showing a rung the hub does not agree with. | +| `GET /consignments/:consignmentid` | The delivery gate reads state in one call instead of pulling and sorting the whole history. The logs walk stays as a fallback so a rider on this build meeting an older deployment is not stranded. | +| `consignmentid` + `consignmentstatus` on lists | The 17/29 blocker is closed, and the row itself now says where the delivery half has got to — so a delivered stop drops off the Deliveries tab without a per-stop round trip. | +| `Arrived_At_Pickup` | Parsed and mapped to the *arrived* rung; **I've arrived** now shows in the console. The undocumented `At_Customer` spelling still parses. | +| Skip from `Collected_By_Miler` | A failed attempt is reportable the moment the parcel is collected, not only once the round has started. | +| Error codes on 4xx | Every branch reads `ApiResult.code` — `INVALID_STATE`, `IDEMPOTENCY_IN_PROGRESS`. No delivery decision is made from message prose any more. | +| `Idempotency-Key` | Sent on `pickup-complete`, `deliver`, `skip`, `payment` and `start-delivery` as a stable `verb:resource:day` key. `IDEMPOTENCY_IN_PROGRESS` is waited out and re-asked twice rather than surfaced — a rider is no longer told his delivery failed while it is in the act of succeeding. | + +**The flag split — confirmed and handled.** `Collected_By_Miler` + +`start-delivery` ship behind `MILER_COLLECTED_STATE_ENABLED`, **default off**, +so hyperlocal pickup still goes straight to `Out_for_Delivery` today. This +build is correct in both worlds and does not need the flag flipped: + +- **Flag off** — the row's `consignmentstatus` already reads `Out_for_Delivery` + when the load reaches the Deliveries tab, so **Start round** spends no + request at all and behaves exactly as it does now. The app never fires a + `start-delivery` that can only be refused. +- **Flag on** — the row reads `Collected_By_Miler`, the press calls + `start-delivery`, and the local released-set is written only for what the + server released. + +Nothing here needs coordinating: flip the flag whenever this build is out. + +**Also shipped, unannounced and now consumed:** `step`, `stoptype`, +`etaminutes`, `cumulativekms` and `cumulativeeta` on the booking row. The +adapter builds a fixed map, so it had been dropping all five; the app now reads +them. `step` in particular is what lets the delivery leg follow the hub's route +instead of re-sorting nearest-first — **but see request 13: it is `0` on every +row.** + +**Still open:** request 4 (upload route for proof of delivery — the photo +cannot leave the phone until this exists), **13** (populate the sequence), +**14** (an outcome for a failed attempt), 7, 8, 9. + +--- + +## 0b. Summary table + +| # | Request | Priority | Status | +|---|---|---|---| +| 1 | A consignment state that means *collected, not yet riding* | **P0** | ✅ Shipped 21 Aug — `Collected_By_Miler` + `start-delivery` | +| 2 | `GET /miler/consignments/:id` (current state, one call) | **P0** | ✅ Shipped 21 Aug | +| 3 | Return `consignmentid` on `GET /miler/bookings` | **P0** | ✅ Shipped 21 Aug — with `consignmentstatus` | +| 4 | A file-upload route for proof of delivery | **P1** | ⏳ **Open** — POD cannot leave the phone | +| 5 | A booking state for *arrived at pickup* | **P1** | ✅ Shipped 21 Aug — `Arrived_At_Pickup` | +| 6 | Allow cancel/skip after pickup, or document the refusal | **P1** | ✅ Shipped 21 Aug — skip from collected | +| 7 | Real route distance + duration on the assignment | **P2** | ⏳ Open — figures are estimates | +| 8 | Persist `bonuspoints`, and a per-stop payout figure | **P2** | ⏳ Open — Earnings shows placeholders | +| 9 | Notification read-state table | **P3** | ⏳ Open — known stub | +| 13 | Route sequence — field shipped, **never populated** | **P0** | ⚠️ **Half** — `step` is on both endpoints but is `0` everywhere, `sequencedat` null | +| 14 | An outcome for a failed delivery attempt | **P1** | ⏳ **Open** — skip leaves the consignment open with no way to say why | +| 15 | `reached` must actually persist `Arrived_At_Pickup` | **P0** | 🔴 **BLOCKER** — reproduced: returns 200, writes nothing | +| 16 | Admin mapping for `Arrived_At_Pickup` + `Collected_By_Miler` | **P0** | 🔴 **Console change** — both absent; flag must not be flipped first | +| 17 | What `/admin/bookings` returns in `status` | **P1** | ❓ Unproven — one console network call answers it | + +--- + +## 1. P0 — There is no consignment state meaning "collected, not yet riding" + +> **✅ Shipped 21 Aug 2026 — Option A.** `pickup-complete` now stops at +> `Collected_By_Miler`; `POST /miler/consignments/:id/start-delivery` makes the +> release. The app's **Start round** is a server write again. + +### Symptom (reported) +> *"If I try to update as Picked it is directly updating as **active** in the +> console."* + +### Evidence — verified +`GET /miler/consignments/logs/34` returns the consignment's real history: + +``` +Out_for_Delivery "Package collected by miler and converted to consignment" +Delivered "Delivered to SEQTEST Ukkadam at (11.00506, 76.95087)" +``` + +The first row is written by **`pickup-complete`** — it carries that handler's +own remark. So on the hyperlocal path, `pickup-complete` moves the consignment +straight to `Out_for_Delivery`. + +The console maps (`src/utils/bookingStatus.js`): + +```js +picked_up: 'picked', +converted_to_consignment: 'picked', +out_for_delivery: 'active', // ← what the operator sees +``` + +So the moment the rider taps **Picked** at the kitchen counter, the consignment +is `Out_for_Delivery` and the operator's board says **active** — while the food +is still on the counter and the rider has not moved. + +### Why this is a backend request, not a UI one +The app already models the distinction locally (it knows the rider has not set +off), but local state cannot reach the hub, and the operator's board is fed +from server state. There is no server state that expresses *collected but not +yet out for delivery*, so the information does not exist to display. + +### Requested change — pick **one** + +**Option A (preferred).** Add a consignment status between conversion and the +road: + +``` +Created → Inwarded_at_Hub → … → Out_for_Delivery → Delivered + ▲ + Collected_By_Miler ← NEW +``` + +`pickup-complete` writes `Collected_By_Miler` for hyperlocal work. +A new call moves it on: + +```http +POST /miler/consignments/:id/start-delivery +→ 200 { success, data: { consignmentid, status: "Out_for_Delivery" } } + 400 if the consignment is not Collected_By_Miler +``` + +`deliver` then accepts `Out_for_Delivery` exactly as it does now. + +**Option B (smaller).** Leave the consignment alone and keep the **booking** at +`Picked_Up` until the rider starts the round, moving it to +`Converted_To_Consignment` at that point. Cheaper, but it leaves the two +vocabularies coupled, which is the root of several problems in this document. + +> **Note on `POST /miler/deliveries/start`:** the app used to call this. It is +> **not** in the 38-route contract and returns `404 Cannot POST` — verified. We +> have removed the call. Request 1 Option A is the properly-specced replacement. + +--- + +## 2. P0 — No way to read a consignment's current state in one call + +> **✅ Shipped 21 Aug 2026.** `GET /miler/consignments/:consignmentid` returns +> the status plus `collected` / `out_for_delivery` / `delivered` / +> `can_start_delivery` / `can_deliver` / `can_skip`. The gate reads it; the +> logs walk remains only as a fallback for older deployments. + +### Symptom (reported) +> *"I can't update it as delivered/skipped/cancelled — it's showing a lot of +> errors."* + +### Evidence — verified +`POST /miler/consignments/34/deliver` → + +```json +400 { "success": false, "message": "consignment is not out for delivery" } +``` + +That message is returned for **two opposite situations**: + +| Real state | Meaning | What the rider should be told | +|---|---|---| +| `Inwarded_at_Hub` | not released yet | "the hub still has it" | +| `Delivered` | **already delivered** | "already done — nothing to do" | + +In the reported case it was the second: consignment 34 had been delivered at +10:59 the previous day. The rider was shown an error for work he had completed. + +### Why the app cannot fix this alone +`GET /miler/bookings` reports only the **booking** status, which is terminal at +`Converted_To_Consignment` and never learns about the delivery half. The only +route that exposes consignment state is +`GET /miler/consignments/logs/:consignmentid`, which returns the whole event +history — the app now sorts it by `historyid` and takes the last row. That +works, but it is a log-walk standing in for a state read, on the rider's mobile +data, before every delivery. + +### Requested change + +```http +GET /miler/consignments/:consignmentid +→ 200 { + success: true, + data: { + consignmentid: 34, + trackingno: "DM-TRK-…", + status: "Out_for_Delivery", + bookingid: 60, + attemptcount: 0, + updatedat: "2026-08-20T10:59:35Z" + } + } + 404 if it is not this rider's consignment +``` + +**And** make the two refusals distinguishable — either distinct messages or, +better, a stable code: + +```json +400 { "success": false, "code": "CONSIGNMENT_ALREADY_DELIVERED", + "message": "consignment already delivered" } +400 { "success": false, "code": "CONSIGNMENT_NOT_RELEASED", + "message": "consignment is not out for delivery" } +``` + +A machine-readable `code` on every 4xx across `/miler/*` would let the app stop +string-matching messages, which is fragile in exactly the way this bug proves. + +--- + +## 3. P0 — `GET /miler/bookings` does not return `consignmentid` + +> **✅ Shipped 21 Aug 2026.** Both `consignmentid` and `consignmentstatus` are +> on the list rows. The 17-of-29 gap is closed, and the consignment status now +> outranks the booking status the app draws the card from. + +### Evidence — verified +24 booking rows returned for rider 38; **zero** contain any `consignment*` key. +`GET /miler/assignments` (list) does not carry it either. It appears **only** in +`GET /miler/assignments/:id`, nested inside `booking`: + +```jsonc +// GET /miler/assignments/71 +{ "data": { "assignment": {…}, "booking": { "bookingid": 75, …, + "consignmentid": 46 } } } +``` + +### This is not a performance concern — it blocks deliveries outright + +**Verified 21 Aug 2026, rider 38:** + +| Endpoint | Rows | Booking ids | +|---|---|---| +| `GET /miler/bookings` | **29** | 26, 28, 41, 51, **54–78**, 82 | +| `GET /miler/assignments` | **12** | 26, 28, 41, 51, 72–78, 82 | + +**17 of 29 bookings have no assignment row at all.** `?status=Completed`, +`?status=all`, `?pagesize=200` and `?bookingid=59` all return the same 12 rows — +the endpoint ignores query parameters. + +So for booking **59** (`DM-BK-501CB551-45130`, status +`Converted_To_Consignment`, sitting on the rider's Deliveries tab) every route +to its consignment id is a dead end: + +1. the booking row — carries no consignment key *(none of the 29 do)* +2. the app's local record from `pickup-complete` — absent after a reinstall, + and absent entirely if that response did not carry the id +3. `GET /miler/assignments/:id` — **booking 59 is not in the assignments list**, + so there is no assignment id to fetch +4. `GET /miler/consignments/userlogs/:userid` — telemetry only (1 row, for an + unrelated consignment); carries no booking linkage + +**There is no fifth route.** The rider is holding the parcel, the consignment +demonstrably exists (the booking is converted), and the app cannot name it — so +`deliver` can never be called and the stop can never be completed from the app. + +This is the single highest-impact item in this document. + +### Requested change +Add `consignmentid` (nullable) and `consignmentstatus` to every row of: + +- `GET /miler/bookings` +- `GET /miler/assignments` + +…and separately, **`GET /miler/assignments` should return every assignment the +rider still has work for**, not a 12-row subset. If that list is intentionally +scoped to active assignments, say so and we will stop treating it as a lookup +table — but then requirement (a) below becomes mandatory rather than merely +strongly preferred. + +```jsonc +{ "bookingid": 75, "status": "Converted_To_Consignment", + "consignmentid": 46, "consignmentstatus": "Out_for_Delivery" } +``` + +This single change removes an N+1 call pattern **and** makes Request 2's read +unnecessary for the list case. + +--- + +## 4. P1 — No upload route: proof of delivery cannot leave the phone + +> **⏳ STILL OPEN.** The photo is captured, previewed and stored on the device, +> and it is shown on the Activity record. It cannot reach the hub until this +> route exists. This is now the highest-priority outstanding item. + +### What the app now does +The rider taps **Delivered** → a proof screen opens the camera → he sees the +photo → **Mark as delivered** on the same screen. The photo is stored on the +handset and shown on the delivery record in Activity. + +### The blocker +`POST /miler/consignments/:id/deliver` takes `photourl` — a **string**. Nothing +in the 38-route contract accepts a file: no multipart route, no signed-URL +endpoint, no attachment on any other call. + +We deliberately **do not** send the device file path in `photourl`. A path from +somebody's phone is not a URL; writing one into the hub's record would store a +string that looks like evidence and resolves to nothing. + +**So proof of delivery currently exists only on the rider's phone**, and the +hub cannot see it. For a delivery business this is the difference between +having evidence and believing you have it. + +### Requested change — either shape works + +**Option A — direct upload (simplest for us).** +```http +POST /miler/uploads +Content-Type: multipart/form-data + file: (jpeg, ≤ 5 MB) + purpose: "delivery_proof" ("pickup_proof" | "signature" | …) + consignmentid: 46 (optional, for association) +→ 201 { success, data: { url: "https://cdn.doormile.com/proof/46-abc.jpg" } } +``` + +**Option B — signed URL (cheaper server-side).** +```http +POST /miler/uploads/sign +{ "purpose": "delivery_proof", "contentType": "image/jpeg" } +→ 200 { success, data: { uploadUrl: "https://…?X-Amz-Signature=…", + url: "https://cdn.doormile.com/proof/…" } } +``` +The app PUTs the bytes to `uploadUrl`, then sends `url` as `photourl`. + +**Constraints from our side:** photos are JPEG, quality 70, max width 1280 — +typically 80–250 KB. Riders are on mobile data and often on 3G, so the upload +must be retryable and must **not** block the delivery: we will complete the +stop and upload in the background, then attach. + +Please also confirm whether `receiversignatureurl` is expected to be fed from +the same mechanism. + +--- + +## 5. P1 — "Arrived" does not persist: there is no booking state for it + +> **✅ Shipped 21 Aug 2026.** `reached` persists `Arrived_At_Pickup`. The app +> parses it (and the older `At_Customer` spelling) to the *arrived* rung. + +### Symptom (reported) +> *"If I click Arrived on the home page it is not updating as arrived."* + +### Evidence +The app calls `POST /miler/bookings/:bookingid/reached` with the booking id — +correct per contract. But the booking enum is: + +``` +Pending_Pickup, Created, Miler_Assigned, Pickup_Scheduled, +Picked_Up, Converted_To_Consignment, Cancelled +``` + +**There is no `Arrived`.** Whatever `reached` writes, the operator's board maps +`pickup_scheduled → 'accepted'`, so an arrived rider is indistinguishable from +one who merely accepted the job and is still 20 km away. + +### Requested change +Add `Arrived_At_Pickup` to the booking enum, written by `reached`, and return +the new status in the response so the app can confirm rather than assume: + +```http +POST /miler/bookings/:bookingid/reached +→ 200 { success, data: { bookingid, status: "Arrived_At_Pickup", + reachedat: "2026-08-21T09:14:02Z" } } +``` + +**Every state-changing route should return the resulting entity state.** Today +most return `{success: true}` with no body, so the app cannot verify the +transition landed and must re-poll the list. + +--- + +## 6. P1 — A rider cannot report a failed delivery + +> **✅ Shipped 21 Aug 2026.** `skip` is accepted from `Collected_By_Miler` as +> well as `Out_for_Delivery`. + +### Symptom (reported) +> *"I can't update it as … skipped/cancelled."* + +### Evidence +- `POST /miler/consignments/:id/skip` requires `Out_for_Delivery` — so a + consignment in any other state cannot be skipped, including one the rider is + genuinely holding. +- `POST /miler/bookings/:id/cancel` is **"refused once picked up"** per the + contract. After `pickup-complete` there is therefore *no* route by which a + rider can say "this cannot be completed" — he is holding a parcel with no way + to report the failure. + +### Requested change +1. Allow `skip` from `Collected_By_Miler` **and** `Out_for_Delivery` (see + Request 1). +2. Add a consignment-level failure route: + +```http +POST /miler/consignments/:id/fail +{ "reason": "Customer refused", "lat": 11.0, "lon": 76.9 } +→ 200 { success, data: { status: "RTO_Initiated", attemptcount: 2 } } +``` + +3. Document the maximum `attemptcount` and what the hub does when it is hit — + the app should stop offering "skip" at that point rather than letting the + rider discover the ceiling by being refused. + +--- + +## 7. P2 — The Home figures are estimates, not real data + +**Direct answer to the question asked.** The four figures on Home +(`DURATION · DISTANCE · PARCELS · PAYMENT`) are: + +| Figure | Source | Real? | +|---|---|---| +| **PARCELS** | sum of parcel counts on the booking rows | ✅ **Real** (backend data) | +| **PAYMENT** | sum of `collectionamt` on the booking rows | ✅ **Real** (backend data) | +| **DISTANCE** | *client-side* haversine: hub → each stop in order → hub, straight lines. Falls back to summing each row's `kms` when coordinates are missing | ⚠️ **Estimate** | +| **DURATION** | *client-side*: straight-line distance ÷ an assumed 20 km/h, **plus** a hardcoded service time per stop — 2m30 delivery / 4m00 pickup / 5m30 combined, +90s if cash, +25s per extra parcel | ⚠️ **Estimate** | + +So two of the four are real and two are the app's own arithmetic. Straight-line +distance under-reads real road distance by roughly 20–40% in Coimbatore, and +the service times are guesses that have never been measured against actual +stop durations. + +### Requested change +Return the routed figures on the assignment/trip, computed once server-side +(you already have the coordinates, and a routing engine gives real road +distance): + +```jsonc +// GET /miler/assignments — per row, or a trip-level summary +{ + "routedistancemeters": 21800, // real road distance for the leg + "routedurationseconds": 1620, // real drive time + "cumulativedistancemeters": 48200, + "cumulativedurationseconds": 5400 +} +``` + +The assignment model already has `riderkms`, `previouskms`, `cumulativekms`, +`etaminutes` and `cumulativeeta` — **all of them return 0** today (verified on +all 24 rows). Populating those existing fields would be enough; no new schema +needed. + +**Until then the app will keep showing estimates, because inventing precision +we do not have is worse than an honest approximation.** + +--- + +## 8. P2 — Earnings has no real per-stop money + +- `bonuspoints` is never written (acknowledged in your own doc). +- There is no per-stop payout anywhere in the API. `deliver` computes + `riderkms` and `ridercharges` server-side and returns **neither**; + `GET /miler/earnings` answers for a *period* only. + +The app therefore shows `Stop payout · See Account` on the delivery record +rather than a number, because a figure derived from a rate the app does not +hold is the one invention a rider would act on and be wrong about. + +### Requested change +Return the earnings the server already computed, on the response to `deliver` +and on the booking/consignment rows: + +```jsonc +{ "riderkms": 4.6, "ridercharges": 38.50, "bonuspoints": 5 } +``` + +--- + +## 9. Battery percentage — direct answer + +**Yes, it can reach the console today, and the plumbing already exists on both +sides.** + +- The app reads the real battery level (`battery_plus`) in + `live_tracking_service.dart` and `foreground_service.dart`. +- It is sent on `POST /miler/logs` and `POST /miler/consignments/logs` as the + documented **string** field `battery`. +- The console already renders it — `src/pages/nearle/riders/riders.js`: + `riderLogsdata?.battery ? \`${battery}%\` : 'N/A'`. + +**What to check on your side:** the console reads it from *rider logs*, so it +only appears while the app is posting telemetry (on duty, service running). If +operators see `N/A`, the likely causes in order are: (a) the rider is off duty +so nothing is being posted; (b) `GET /miler/logs` is returning the latest row +without `battery` populated; (c) the value is being stored but the console's +rider-detail query is not selecting it. + +**One request:** expose the most recent telemetry row per rider in one call, so +the console does not have to page logs to find a current battery/GPS reading: + +```http +GET /miler/riders/:userid/last-seen (or /admin equivalent) +→ { success, data: { latitude, longitude, battery, speed, status, + logdate, connection } } +``` + +--- + +## 10. Cross-cutting requests + +> **✅ 10.1–10.4 shipped 21 Aug 2026.** Mutations return the resulting state, +> `pickup-complete` returns `consignment_id` and `next_action`, 4xx bodies +> carry a machine-readable `code`, and `Idempotency-Key` is honoured. 10.5 +> (`requiredeliveryotp` on the tenant payload) is still open. + +These are small and would remove whole classes of defect: + +1. **Return the resulting state from every mutation.** `accept`, `reject`, + `reached`, `parcel`, `payment`, `pickup-complete`, `deliver`, `skip`, + `cancel` mostly return `{success: true}`. The app cannot confirm a + transition and must re-poll. + +2. **`pickup-complete` must return the `consignmentid` it minted.** It is the + one moment the id is guaranteed to exist. The app records it locally from + the response today; when the response omits it, the delivery leg has to go + hunting (Request 3). + +3. **Machine-readable error codes on every 4xx** (see Request 2). String + matching on `"consignment is not out for delivery"` is how the app ended up + telling riders the wrong thing. + +4. **Idempotency.** `deliver`, `pickup-complete` and `accept` are not + idempotent. A dropped response on a bad connection means the rider retries + and gets a 400 for work that succeeded. Accepting an + `Idempotency-Key` header and replaying the original result would remove + this entire failure mode. + +5. **Confirm `requiredeliveryotp` per tenant** is exposed to the app. We + currently send `otp` only when we have one and never fabricate a + placeholder; if the flag were on the profile/tenant payload we could show + or hide the OTP field correctly instead of inferring. + +--- + +## 11. What the app already does correctly (no change needed) + +Recorded so the backend team does not chase these: + +- `deliver`/`skip` key on the **consignment id**, never the booking, assignment + or pickup id. +- `accept`/`reject` key on `bookingassignmentid`. +- `reached`/`parcel`/`payment`/`pickup-complete`/`cancel` key on `bookingid`. +- Telemetry sends lat/long/speed/heading/battery as **strings**, and never + sends `userid` in the body. +- Booking statuses and consignment statuses are handled as two separate + vocabularies; the app no longer infers a delivery state from a booking + status. +- Duplicate taps are collapsed client-side (one in-flight mutation per + resource+verb), but see Request 10.4 — that is not a substitute for + server-side idempotency. + +--- + +## 11b. Request 13 — P0: the route sequence is exposed but never populated + +**Re-verified live 21 Aug 2026** against rider `userid 23 / tenantid 1` — +`GET /miler/bookings` (29 rows) and `GET /miler/assignments` (11 rows). + +### The transport is there. Thank you. + +`step` is on **both** endpoints, alongside `sequencedat`, `etaminutes`, +`cumulativekms`, `cumulativeeta` and `stoptype`. That closes the client half of +this entirely: the app now reads `step` off the booking row, orders both legs +by it, and never re-sorts an assigned route. + +### The data is empty + +``` +/miler/bookings step = 0 on all 29 rows +/miler/assignments step = 0 on all 11 rows + sequencedat = null on all 11 rows + etaminutes = 0, riderkms = 0, cumulativekms = 0 +``` + +`sequencedat: null` on every assignment means **the route optimizer has never +run for this rider**. So there is no admin route to follow — not one the app is +dropping, one that does not exist. + +### What the app does about it + +It does not pretend. With no sequence anywhere in the set it falls back — +booked time first, then nearest-first — and **labels the queue on screen** with +which rule produced the order (`Hub route` vs `Nearest first`), so a rider is +never told the app's guess is his route. It is also logged per fetch: + +``` +[ROUTE][deliveries] 7 stop(s) with no assigned sequence — ordering is the +app's own fallback, not the hub's route +``` + +### Requested change + +Populate it. Whatever writes `sequencedat` — batch assign, the console's +sequence action, a scheduled optimizer pass — is either not running or not +running for this tenant. Once `step` comes down non-zero, the app follows it on +both legs with no further change and no release needed. + +**Please confirm:** is sequencing expected to be automatic on assignment, or is +it a deliberate action a hub manager has to take in the console? The answer +changes whether "no route assigned" is a normal state the rider should see or a +fault worth alerting on. + +--- + +## 11c. Request 14 — P1: a failed delivery attempt has no outcome + +`POST /miler/consignments/:id/skip` returns 200 and, as far as the app can +tell, leaves the consignment `Out_for_Delivery`. There is no state, field or +route that says *this stop was attempted and failed*. + +That leaves the two systems unable to agree on whether the stop is still +actionable: + +- The **rider** has been to the door. It is done with, for him, today. +- The **hub** still has an open `Out_for_Delivery` consignment. + +The app refuses to resolve that by inventing a terminal state locally — it +reads the consignment after every skip and only files the stop as finished if +the server agrees it is closed. When the server keeps it open, the stop is +**parked**: still visible under SKIPPED with the rider's reason, still holding +its consignment mapping, resumable by him. Honest, but it is a workaround for a +missing contract. + +### Requested change — any one of these + +1. A consignment state for it — `Delivery_Failed` / `Attempt_Failed` — that + `skip` moves it to, with whatever reassignment or RTO the hub decides + happening from there. +2. An `attemptcount` + `lastattemptat` on the consignment, so the app can at + least show *"attempt 2 of 3"* and the hub can act on the count. +3. Documented confirmation that a skip is expected to leave the consignment + open and that the rider is meant to retry it in the same shift — in which + case the app will surface it as retryable rather than as a closed record. + +Whichever you pick, the app needs to know **who owns the stop after a skip**. + +--- + +## 11d. Request 15 — P0 BLOCKER: `reached` is a deployed no-op + +**Reproduced against production 21 Aug 2026.** Rider `userid 23 / tenantid 1`, +booking 78, three calls in sequence: + +``` +GET /miler/bookings → booking 78 status = Miler_Assigned + +POST /miler/bookings/78/reached + {"lat":11.0168,"lon":76.9558} + → 200 {"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}} + +GET /miler/bookings → booking 78 status = Miler_Assigned +``` + +The endpoint returns `success: true`, echoes the booking's **unchanged** +status, and writes nothing. `Arrived_At_Pickup` is not written and does not +appear on any of the 31 bookings in this tenant. + +**This is the whole reason Arrived never reaches Admin.** It is not a client +bug: the app calls the documented endpoint with the documented body and gets a +200. It had been treating that 200 as proof of a transition, which it no longer +does — the app now reads the returned status and, when it is not +`Arrived_At_Pickup`, tells the rider his hub has not recorded the arrival +rather than drawing a tick. + +### Requested change + +`POST /miler/bookings/:bookingid/reached` must persist `Arrived_At_Pickup` on +the booking and return it: + +``` +POST /miler/bookings/:bookingid/reached +{ "lat": 11.0168, "lon": 76.9558 } + +200 { "success": true, + "data": { "bookingid": 78, "status": "Arrived_At_Pickup" } } +``` + +**Please also confirm which is true**, because they need different fixes: +1. the handler was never wired to write, or +2. it writes only from `Pickup_Scheduled` and silently no-ops from + `Miler_Assigned` (booking 78 was assigned but not yet accepted). + +If (2), say so and the app will stop offering **I've arrived** before the +accept lands. If it is meant to be callable from `Miler_Assigned`, it must +write from there too. + +--- + +## 11e. Request 16 — P0: Admin has no mapping for the two new statuses + +**Not a backend change — a console change.** Traced in +`Doormilexpress_console/src/utils/bookingStatus.js`: + +```js +export const BOOKING_STATUS_TO_DELIVERY_STATUS = { + created: 'pending', pending_pickup: 'pending', + miler_assigned: 'pending', pickup_scheduled: 'accepted', + picked_up: 'picked', converted_to_consignment: 'picked', + out_for_delivery: 'active', + delivered: 'delivered', cancelled: 'cancelled' +}; +``` + +Neither `arrived_at_pickup` nor `collected_by_miler` is in it. Unmapped +statuses "pass through lowercased", which the Deliveries page renders as an +unknown badge — and the file's own comment says the Arrived tab "will show a 0 +count until the real enum is confirmed". + +Two consequences the console team must act on: + +1. **Even after request 15 ships, Admin still will not show Arrived.** Add + `arrived_at_pickup: 'arrived'`. +2. **Enabling `MILER_COLLECTED_STATE_ENABLED` today would make Admin worse, + not better.** With no `collected_by_miler` key, every freshly collected + parcel would render as an unknown badge instead of Active. Add + `collected_by_miler: 'picked'` **before** the flag is flipped. + +### Ordering — this matters + +``` +1. console adds arrived_at_pickup + collected_by_miler mappings +2. backend fixes `reached` to persist Arrived_At_Pickup (request 15) +3. this app build ships — it already supports both lifecycles +4. only then flip MILER_COLLECTED_STATE_ENABLED = true +``` + +Flipping the flag before step 1 or step 3 breaks the rider's Picked rung. + +--- + +## 11f. Request 17 — the one thing still unproven: what `/admin/bookings` puts in `status` + +`GET /miler/bookings` reports the **booking** status: booking 77 is +`Converted_To_Consignment` with `consignmentstatus: Out_for_Delivery`. + +The console reads `b.status` from `/admin/bookings` and maps it +(`api.js:611` and `api.js:664`). If that field held `Converted_To_Consignment` +too, the console would render **Picked** — which is not what we see; it shows +**Active**. + +So exactly one of these is true, and we cannot tell which from the rider token: + +| # | Possibility | How to confirm | Owner | +|---|---|---|---| +| A | `/admin/bookings` projects the consignment status onto `status` | one network call in the console's dev tools | backend | +| B | the backend also writes `Out_for_Delivery` onto the booking row for hyperlocal, and `/miler/bookings` reports something different | compare both endpoints for the same bookingid | backend | +| C | the deployed console is older than this source (the repo comment says `converted_to_consignment` "used to map to `accepted`") | check the deployed bundle | console | + +**Please open the console's Deliveries page, look at the `/admin/bookings` +response for one collected booking, and tell us the value of `status`.** That +single value decides which of the three it is, and none of them is fixed in the +rider app. + +Whichever it is, note that in compatibility mode **Active is not wrong** — the +consignment genuinely is `Out_for_Delivery`. The app now agrees with the +console instead of showing `Picked` over it. Picked/Collected becomes a real, +separate rung only once the flag is on. + +--- + +## 12. Open questions for the backend team + +~~1. Is `Collected_By_Miler` (Request 1 Option A) acceptable, or do you prefer + holding the booking at `Picked_Up` (Option B)?~~ **Answered: Option A, shipped.** + +2. Which upload shape do you want to support — direct multipart or signed URL? +3. Is there an existing object store / CDN we should target for proof photos, + and what retention applies to them? +4. Can `riderkms` / `etaminutes` / `cumulativekms` be populated from the + routing engine, or is there no routing engine in the stack today? +5. What is the intended `attemptcount` ceiling, and what happens at it? diff --git a/README.md b/README.md deleted file mode 100644 index 48e205e..0000000 --- a/README.md +++ /dev/null @@ -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. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 1f88145..d1362a5 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -19,6 +19,59 @@ subprojects { 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().configureEach { + sourceCompatibility = JavaVersion.VERSION_17.toString() + targetCompatibility = JavaVersion.VERSION_17.toString() + } + tasks.withType() + .configureEach { + compilerOptions.jvmTarget.set( + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17, + ) + } + } + } +} + tasks.register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/assets/images/ChatGPT Image Aug 7, 2026, 01_02_12 PM.png b/assets/images/ChatGPT Image Aug 7, 2026, 01_02_12 PM.png deleted file mode 100644 index f534d45..0000000 Binary files a/assets/images/ChatGPT Image Aug 7, 2026, 01_02_12 PM.png and /dev/null differ diff --git a/assets/images/Doormile Bike.png b/assets/images/Doormile Bike.png new file mode 100644 index 0000000..513f937 Binary files /dev/null and b/assets/images/Doormile Bike.png differ diff --git a/assets/images/bookings_taken.png b/assets/images/bookings_taken.png new file mode 100644 index 0000000..c25f7b2 Binary files /dev/null and b/assets/images/bookings_taken.png differ diff --git a/assets/images/caught_up.png b/assets/images/caught_up.png new file mode 100644 index 0000000..583b60f Binary files /dev/null and b/assets/images/caught_up.png differ diff --git a/assets/images/homeicon.png b/assets/images/homeicon.png new file mode 100644 index 0000000..418690e Binary files /dev/null and b/assets/images/homeicon.png differ diff --git a/assets/images/in.png b/assets/images/in.png new file mode 100644 index 0000000..29bc687 Binary files /dev/null and b/assets/images/in.png differ diff --git a/assets/images/information-point.png b/assets/images/information-point.png new file mode 100644 index 0000000..5dfaab2 Binary files /dev/null and b/assets/images/information-point.png differ diff --git a/assets/images/map.png b/assets/images/map.png new file mode 100644 index 0000000..29b5059 Binary files /dev/null and b/assets/images/map.png differ diff --git a/assets/images/no_activity_yet.png b/assets/images/no_activity_yet.png new file mode 100644 index 0000000..0a3dee9 Binary files /dev/null and b/assets/images/no_activity_yet.png differ diff --git a/assets/images/no_bookings_available.png b/assets/images/no_bookings_available.png new file mode 100644 index 0000000..d93b5bc Binary files /dev/null and b/assets/images/no_bookings_available.png differ diff --git a/assets/images/no_bookings_available1.png b/assets/images/no_bookings_available1.png new file mode 100644 index 0000000..c321a43 Binary files /dev/null and b/assets/images/no_bookings_available1.png differ diff --git a/assets/images/nothing_finished_yet.png b/assets/images/nothing_finished_yet.png new file mode 100644 index 0000000..af12cc2 Binary files /dev/null and b/assets/images/nothing_finished_yet.png differ diff --git a/assets/images/ChatGPT Image Aug 7, 2026, 01_01_54 PM.png b/assets/images/onboarding_img_1.png similarity index 100% rename from assets/images/ChatGPT Image Aug 7, 2026, 01_01_54 PM.png rename to assets/images/onboarding_img_1.png diff --git a/assets/images/onboarding_img_2.png b/assets/images/onboarding_img_2.png new file mode 100644 index 0000000..e0ee9d7 Binary files /dev/null and b/assets/images/onboarding_img_2.png differ diff --git a/assets/images/onlinebottom.png b/assets/images/onlinebottom.png new file mode 100644 index 0000000..7fb6511 Binary files /dev/null and b/assets/images/onlinebottom.png differ diff --git a/assets/images/onlineoffline.png b/assets/images/onlineoffline.png new file mode 100644 index 0000000..c1c644f Binary files /dev/null and b/assets/images/onlineoffline.png differ diff --git a/assets/images/orderssample.png b/assets/images/orderssample.png new file mode 100644 index 0000000..2d96571 Binary files /dev/null and b/assets/images/orderssample.png differ diff --git a/assets/images/pending.png b/assets/images/pending.png new file mode 100644 index 0000000..9d62a1d Binary files /dev/null and b/assets/images/pending.png differ diff --git a/assets/images/phone-call .png b/assets/images/phone-call .png new file mode 100644 index 0000000..147fe27 Binary files /dev/null and b/assets/images/phone-call .png differ diff --git a/assets/images/profileicon.png b/assets/images/profileicon.png new file mode 100644 index 0000000..7957b07 Binary files /dev/null and b/assets/images/profileicon.png differ diff --git a/assets/images/selcart.png b/assets/images/selcart.png new file mode 100644 index 0000000..eb96fcd Binary files /dev/null and b/assets/images/selcart.png differ diff --git a/assets/images/selecteddelivery.png b/assets/images/selecteddelivery.png new file mode 100644 index 0000000..c9e3fa1 Binary files /dev/null and b/assets/images/selecteddelivery.png differ diff --git a/assets/images/selectedprofile.png b/assets/images/selectedprofile.png new file mode 100644 index 0000000..195001f Binary files /dev/null and b/assets/images/selectedprofile.png differ diff --git a/assets/images/selectedsummary.png b/assets/images/selectedsummary.png new file mode 100644 index 0000000..dd53eee Binary files /dev/null and b/assets/images/selectedsummary.png differ diff --git a/assets/images/selecthome.png b/assets/images/selecthome.png new file mode 100644 index 0000000..41145b1 Binary files /dev/null and b/assets/images/selecthome.png differ diff --git a/assets/images/shoppingbag.png b/assets/images/shoppingbag.png new file mode 100644 index 0000000..be56a7e Binary files /dev/null and b/assets/images/shoppingbag.png differ diff --git a/assets/images/summary.png b/assets/images/summary.png new file mode 100644 index 0000000..fae3006 Binary files /dev/null and b/assets/images/summary.png differ diff --git a/assets/images/today.png b/assets/images/today.png new file mode 100644 index 0000000..86cfb83 Binary files /dev/null and b/assets/images/today.png differ diff --git a/assets/images/total.png b/assets/images/total.png new file mode 100644 index 0000000..3150d7c Binary files /dev/null and b/assets/images/total.png differ diff --git a/assets/images/trip_not_assigned.png b/assets/images/trip_not_assigned.png new file mode 100644 index 0000000..0d68dc8 Binary files /dev/null and b/assets/images/trip_not_assigned.png differ diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 09c816f..fa05c1b 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -108,6 +108,15 @@ "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", "kind" : "remoteSourceControl", diff --git a/lib/Models/stop_status.dart b/lib/Models/stop_status.dart index 139e5cb..451afab 100644 --- a/lib/Models/stop_status.dart +++ b/lib/Models/stop_status.dart @@ -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 /// free-form `orderstatus` string. /// @@ -16,6 +22,32 @@ enum StopStatus { active, arrived, 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, cancelled, rejected, @@ -44,6 +76,21 @@ StopStatus stopStatusFromRaw(dynamic raw) { case 'pickuped': case 'pickedup': 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': + case 'at customer': + case 'at_customer': + return StopStatus.deliveryArrived; + case 'delivered': + return StopStatus.delivered; case 'skipped': return StopStatus.skipped; case 'cancelled': @@ -67,11 +114,53 @@ extension StopStatusX on StopStatus { bool get isActive => this == StopStatus.active; bool get isRejected => this == StopStatus.rejected; - /// A completed pickup — picked up or cancelled. (Matches the long-standing - /// `!= 'picked' && != 'picked up' && != 'cancelled'` filter used to build the - /// "next stops" and remaining-pickups lists.) - bool get isFinishedPickup => - this == StopStatus.picked || this == StopStatus.cancelled; + /// On the customer round: released, at a door, or handed over. + bool get isDeliveryLeg => + this == StopStatus.outForDelivery || + this == StopStatus.deliveryArrived || + 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. bool get isPending => @@ -94,12 +183,68 @@ extension StopStatusX on StopStatus { StopStatus.accepted => 'Accepted', StopStatus.active => 'In progress', 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.cancelled => 'Cancelled', StopStatus.rejected => 'Rejected', 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). @@ -110,4 +255,17 @@ class OrderAction { static const String picked = 'PICKED'; static const String rejected = 'REJECTED'; 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'; } diff --git a/lib/controllers/auth.dart b/lib/controllers/auth.dart index e803a68..d2e645f 100644 --- a/lib/controllers/auth.dart +++ b/lib/controllers/auth.dart @@ -1,8 +1,8 @@ +import 'dart:convert'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.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:shared_preferences/shared_preferences.dart'; import 'package:miler/providers/auth/auth_provider.dart'; @@ -10,6 +10,7 @@ import 'package:miler/utils/device.dart'; import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/Models/login/login.dart'; import 'package:miler/data/api_config.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; enum AuthNext { verifyPin, otp, notRegistered, error } @@ -20,6 +21,20 @@ class AuthController extends GetxController { AuthNext? lastDecision; // Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry" 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 _prefsPendingPinUserIdKey = 'pending_pin_userid'; static const String _prefsUserNameKey = 'user_name'; @@ -56,54 +71,31 @@ class AuthController extends GetxController { } 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( - Builder( - builder: (context) => Container( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewPadding.bottom, - ), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.info_outline, color: ColorConstants.primary, size: 40), - 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(); - }, - ), - ], - ), + MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MilerSheetHeader( + title: title, + subtitle: message, + icon: LucideIcons.info, + ), + const SizedBox(height: 18), + MilerButton( + label: 'Retry', + onPressed: () { + Get.back(); + // If MPIN screen has registered a retry callback, run it + onPinRetry?.call(); + }, + ), + ], ), ), isScrollControlled: true, @@ -130,13 +122,19 @@ class AuthController extends GetxController { // Create-MPIN, which would overwrite the PIN the account was issued. // Seeded development accounts take exactly this branch — enter the phone, // enter the seeded MPIN, done. + // Null means the directory could not be reached — see + // [AuthProvider.milerAccountExists]. Treat it as "he has an account", + // because that is true of every rider who gets this far and because the + // MPIN screen is the only one that can tell him what went wrong. The OTP + // branch is the dead end: it ends at Create-MPIN, which cannot write a + // PIN, so guessing wrong in that direction locks a rider out. final exists = await _api.milerAccountExists(normalized); - if (exists) { + if (exists ?? true) { lastDecision = AuthNext.verifyPin; return lastDecision!; } - // Unknown number (or legacy backend) — fall through to the OTP step. + // The directory answered, and said there is no such account. lastDecision = AuthNext.otp; return lastDecision!; } catch (e) { @@ -160,8 +158,24 @@ 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 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; } @@ -185,15 +199,6 @@ class AuthController extends GetxController { ); return false; } - // Demo mode: the mocked login flow stores a fake rider id (9999) that - // the live server rejects, so updatePin fails. Save the PIN locally and - // report success so the demo Create-MPIN flow works without a real - // account or server call. - if (userId == 9999) { - await prefs.setString('dbPin', newPin); - await prefs.remove(_prefsPendingPinUserIdKey); - return true; - } final int pinNum = int.parse(newPin); final res = await _api.updatePin(userId: userId, pin: pinNum); @@ -202,13 +207,17 @@ class AuthController extends GetxController { 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', - ); + // The server's own sentence, not a slice of its JSON. A rider reading + // `{"status":false,"code":403,...}` learns nothing he can act on. + String reason = ''; + try { + final decoded = json.decode(res.body); + if (decoded is Map) reason = (decoded['message'] ?? '').toString(); + } catch (_) {} + if (reason.trim().isEmpty) { + reason = 'Could not set your MPIN. Please contact your hub manager.'; + } + _showBottomSheet(title: 'MPIN not changed', message: reason); return false; } catch (e) { debugPrint('setPin error: $e'); @@ -281,15 +290,52 @@ class AuthController extends GetxController { currentPhone ?? prefs.getString(_prefsContactNoKey) ?? ''; final int? pinNum = int.tryParse(inputPin); if (phone.isEmpty || pinNum == null || inputPin.length != 4) { - _showBottomSheet( - title: 'Invalid PIN', - message: 'Please enter your 4-digit PIN and try again.', - ); + // Two different faults wearing one message. A missing phone is not the + // rider mistyping — it means he reached this screen without the number + // 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; } - final deviceId = await DeviceUtils.ensureDeviceId(prefs); - final fcmToken = await DeviceUtils.ensureFcmToken(prefs); + // ── Nothing gathered here may stop the sign-in ── + // + // 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( contactNo: phone, deviceType: Platform.operatingSystem, @@ -300,9 +346,38 @@ class AuthController extends GetxController { 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 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 the hub — it is not your PIN.'; + _showBottomSheet( + title: 'Could not start session', + message: lastPinFailure!, + ); + return false; + } + if (ok) { + lastPinFailure = null; await prefs.setString('dbPin', inputPin); await prefs.setBool('logged_out', false); currentPhone = _normalizePhone(phone); @@ -329,19 +404,40 @@ class AuthController extends GetxController { return true; } - _showBottomSheet( - title: 'Login failed', - message: (res.message != null && res.message!.trim().isNotEmpty) - ? res.message! - : 'Incorrect phone number or PIN. Please try again.', - ); + // ── Only 401/403 is a statement about the PIN ── + // + // Everything else — a 502, a gateway timeout, a captive portal, a body + // that is not JSON — is the sign-in failing to *complete*, which is a + // 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; } 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'); - _showBottomSheet( - title: 'Connection error', - message: 'Could not reach the server. Check your internet and retry.', - ); + lastPinFailure = + 'Could not reach the server. Check your connection and try again.'; + _showBottomSheet(title: 'Connection error', message: lastPinFailure!); return false; } } diff --git a/lib/controllers/logcontroller.dart b/lib/controllers/logcontroller.dart index 1841c37..ef5f36b 100644 --- a/lib/controllers/logcontroller.dart +++ b/lib/controllers/logcontroller.dart @@ -131,7 +131,6 @@ class LogController extends GetxController { final List remaining = []; bool anySuccess = false; - for (final itemStr in queue) { try { final Map item = jsonDecode(itemStr); diff --git a/lib/controllers/pickups_controller.dart b/lib/controllers/pickups_controller.dart index 3bfd361..40d96a6 100644 --- a/lib/controllers/pickups_controller.dart +++ b/lib/controllers/pickups_controller.dart @@ -1,6 +1,9 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; + +import 'package:miler/data/work_repository.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:miler/helpers/miler_router.dart'; @@ -17,16 +20,61 @@ import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/controllers/connectivity_mixin.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/data/meal_run_mock.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; -/// Testing/demo flag. When `true`, the proximity ("you are too far from the -/// location") geofence is skipped so mock stops far from the rider can still be -/// completed while testing the design. +/// ── PROXIMITY ENFORCEMENT IS OFF ── /// -/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds — the -/// geofence can never be accidentally shipped disabled, but debug/testing keeps -/// working with mock stops. -const bool kBypassGeofenceForTesting = kDebugMode; +/// Turned off deliberately on 2026-08-19, at the founder's call, because it was +/// refusing real work: riders pressing **Picked up** at a counter were told +/// "You're 4.2 km from this stop", and a rider who cannot record what he has +/// physically done has no way round it. Correctness of the fence matters less +/// than a rider being able to work. +/// +/// It is off for **every** status — accepted, arrived, picked up, out for +/// delivery, delivered — and on both gates, this one and the bulk check on +/// Home. Half a fence is worse than none: it would block one route into a rung +/// and wave through another, with two different messages and no explanation of +/// why one worked. +/// +/// ── Turning it back on ── +/// +/// flutter run --dart-define=ENFORCE_GEOFENCE=true +/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true +/// +/// Or flip [kGeofenceEnforced]'s default back to `true` when the underlying +/// problem is fixed. That problem is worth naming, because it is the reason +/// this is off rather than merely loosened: the fence compares the rider's GPS +/// against `pickuplat`/`pickuplon` **on the booking**, and those come from +/// wherever the customer dropped a pin — so the distance it measures is as +/// often the data being wrong as the rider being absent. +/// +/// ── What it costs while it is off ── +/// +/// This is the one control that decided whether the app was telling the truth +/// about where a rider was standing when he said a stop was done. With it off, +/// "Picked up" means he pressed a button, not that he was there — the +/// timestamps and the GPS still ride along on every status write, so the office +/// can audit after the fact, but nothing is refused at the moment of the press. +/// +/// Every bypass is logged in every build mode (see `_checkGeofence`), so a +/// build's own log says which way it was compiled. +/// +/// ── The history, so neither old mistake comes back ── +/// +/// This was once `kDebugMode`, which meant the fence was off in exactly the +/// builds anyone tested with — so it was never really tested. It was then +/// pinned to a hard `false`, which left no way to walk the flow at a desk. The +/// shape below survives both: one named constant, one define, one default, and +/// the default is a product decision rather than a side effect of how the app +/// was built. +const bool kGeofenceEnforced = bool.fromEnvironment( + 'ENFORCE_GEOFENCE', + defaultValue: false, +); + +/// The name every call site reads. Derived, so there is one switch and not two. +const bool kBypassGeofenceForTesting = !kGeofenceEnforced; class PickupsController extends GetxController with ConnectivityControllerMixin { @@ -66,6 +114,17 @@ class PickupsController extends GetxController // Bonus Points Tracking final RxInt lastBonusPoints = 0.obs; + /// The tracking number the last `pickup-complete` minted, or '' when the call + /// has not run or did not return one. + /// + /// `pickup-complete` is the moment a booking becomes a real consignment, and + /// it answers with `{tracking_no, consignment_id, booking_no}` — the shipment + /// the rider has just brought into existence. That payload was being + /// discarded, so the success screen could only say "done" in the abstract. + /// Showing the tracking number makes it a receipt: the rider can read it back + /// to a customer who asks, on the spot, without phoning the hub. + final RxString lastTrackingNo = ''.obs; + // ── Compliance of the stop just completed ── // // All three are computed here already — the bonus rule is literally "did he @@ -79,12 +138,35 @@ class PickupsController extends GetxController // be measured) — which the UI states as unknown rather than as a failure. final Rxn lastOnTime = Rxn(); final Rxn lastLateBy = Rxn(); + + /// The clock the stop was promised by, as read from `eta_endtime_` + /// at the moment it closed. + /// + /// The verdict — on time or late by how much — was the only thing kept, and + /// it answers "did he make it" without ever answering "by when". A rider + /// asked about a stop wants both, and the deadline itself lives in a prefs + /// key the next stop overwrites. Held here so the completion path can write + /// it onto the record, the same argument [StopCompliance] makes for the two + /// figures beside it. + final Rxn lastEtaDeadline = Rxn(); final RxDouble lastRiderKms = 0.0.obs; // Trigger to refresh pickup list across screens final RxInt refreshTrigger = 0.obs; + /// ── After a mutation, the server is asked, not assumed ── + /// + /// This bumped a counter and every screen listening re-read *its own* copy of + /// the day — so accepting a stop on Home updated Home, and Bookings and + /// Activity carried on showing the state from before the tap until their own + /// poll came round. + /// + /// The shared copy is invalidated first, so the re-read that the counter + /// triggers goes to the API rather than to a cached answer that predates the + /// mutation. One request, and all three screens land on the state the server + /// actually confirmed rather than on the one the button implied. void triggerRefresh() { + unawaited(WorkRepository.instance.invalidate()); refreshTrigger.value++; } @@ -95,11 +177,42 @@ class PickupsController extends GetxController int userId, int pickupId, ) async { + // Nothing to store proof against on the meal line — the crate photo has no + // booking behind it yet, so it stays on the phone rather than filling a + // bucket with pictures of a day that only exists in memory. + if (MealRunMock.active) { + debugPrint('[MEAL_MOCK] crate photo kept local: ${imageFile.path}'); + return null; + } + + // ── These were literals in the source ── + // + // A DigitalOcean Spaces access key and secret key, compiled into every APK + // the app has ever shipped. Anyone with the binary can extract them with + // `strings`, and they are read-write on the whole `doormile` bucket — every + // rider's proof photo, for every tenant, readable and deletable by anybody + // who has installed the app once. + // + // Moving them to `--dart-define` stops the next build embedding them. It + // does **not** undo the exposure: the pair below has been distributed and + // is in this repository's history, so it has to be **rotated**, and the + // upload belongs behind a server-issued signed URL rather than in the + // client at all. Both of those are ops decisions, so this change makes the + // dependency explicit and loud rather than pretending to fix it. + const String accessKey = String.fromEnvironment('SPACES_KEY'); + const String secretKey = String.fromEnvironment('SPACES_SECRET'); + if (accessKey.isEmpty || secretKey.isEmpty) { + debugPrint( + '[UPLOAD] refused: SPACES_KEY/SPACES_SECRET are not set on this build. ' + 'Pass them with --dart-define, and rotate the pair that used to be ' + 'hard-coded here.', + ); + AppFeedback.errorGlobal('Photo upload is not configured on this build.'); + return null; + } + try { - // final rng = Random(); // Unused const String region = "sgp1"; - const String accessKey = "DO00NQER7N2FRYZAB2HR"; - const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0"; const String bucketName = "doormile"; // folderName will be "picked" or "Picked up" @@ -237,10 +350,40 @@ class PickupsController extends GetxController } // Picked + /// ── The meal line has nothing to post to ── + /// + /// There are no meal endpoints. Every status method below would otherwise + /// build a payload and fire it at a parcel URL that has never heard of a + /// crate — and, worse, run the proximity check against a mock address in + /// Coimbatore, so a rider walking the flow anywhere else is blocked at the + /// first door by a fence guarding fictional coordinates. + /// + /// So on that line the status is recorded in [MealRunMock] and reported as + /// sent. The flow is identical; only the wire is absent. Returns null when + /// this is a real parcel call, so the caller carries on. + /// + /// See [MealRunMock] for why this cannot reach a parcel rider. + bool? _mockStatus(int pickupId, String status) { + if (!MealRunMock.active) return null; + lastBlockedReason = null; + return MealRunMock.setStatusByPickupId(pickupId, status); + } + Future updatePickedStatus({ required int pickupId, required int orderHeaderId, required int pickupLocationId, + + /// ── The key the delivery leg will look this stop up by ── + /// + /// `pickup-complete` is what creates the consignment, and the id it returns + /// is filed against this order so the door can find it later. The payload + /// carried no `orderid`, so the provider fell back to the *booking* id — + /// and `closeDelivery` reads the map by **order** id. Every collected stop + /// was therefore filed under a key nothing would ever ask for, and pressing + /// **Delivered** answered "this order was never picked up on the system" + /// for a bag the rider was holding. See [rememberConsignmentId]. + String orderId = '', String ridersLat = '0', String ridersLng = '0', String pickupLat = '0', @@ -255,6 +398,9 @@ class PickupsController extends GetxController double actualKms = 0.0, String? proofImage, // New parameter }) async { + final mock = _mockStatus(pickupId, 'picked'); + if (mock != null) return mock; + try { final now = DateTime.now(); _pickedTime = _formatDateTimeFull(now); @@ -307,6 +453,9 @@ class PickupsController extends GetxController final payload = { 'pickupid': pickupId, + // Not sent to the backend — the legacy shape carries it and the mapper + // reads it to file the consignment under the id the door will use. + if (orderId.isNotEmpty) 'orderid': orderId, 'orderheaderid': orderHeaderId, 'pickuplocationid': pickupLocationId, 'orderstatus': 'picked', @@ -336,6 +485,30 @@ class PickupsController extends GetxController if (!ok) { debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}'); } else { + // ── What the stop actually became, in the server's words ── + // + // The pivot has two legitimate outcomes and the caller must stamp the + // one that happened, not the one this build was written against: + // + // `collectedByMiler` the rider holds it — PICKED, and Start + // delivery is what makes it active. + // `outForDelivery` compatibility mode — the pivot released it, + // so it is ALREADY out for delivery and the + // console is right to say Active. + // + // Stamping 'picked' in the second case is the mismatch this whole + // exercise is about: the rider would read PICKED off a consignment his + // office reads as Active, and neither of them would be wrong about + // what their own screen said. + lastPivotConsignmentStatus = (resp?['consignmentstatus'] ?? '') + .toString(); + lastPivotCompatibilityMode = resp?['compatibilitymode'] == true; + lastPivotNextAction = (resp?['nextaction'] ?? '').toString(); + debugPrint( + '[UPDATE][PICKED] server state="$lastPivotConsignmentStatus" ' + 'next="$lastPivotNextAction" ' + 'compatibility=$lastPivotCompatibilityMode', + ); // --- MQTT LOGIC --- MilerMqttService().publishLog('pickup_picked', payload); } @@ -366,7 +539,11 @@ class PickupsController extends GetxController String orderId = '', // New parameter for timer/bonus logic String? proofImage, // New parameter }) async { + final mock = _mockStatus(pickupId, 'picked'); + if (mock != null) return mock; + try { + lastBlockedReason = null; pickedShimmer.value = true; // ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately final prefsFuture = SharedPreferences.getInstance(); @@ -620,6 +797,7 @@ class PickupsController extends GetxController // stale value from the previous one would be stamped onto this record. lastOnTime.value = null; lastLateBy.value = null; + lastEtaDeadline.value = null; lastRiderKms.value = calculatedRiderKms; if (orderId.isNotEmpty) { try { @@ -629,6 +807,9 @@ class PickupsController extends GetxController if (endSeconds != null) { final currentSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + lastEtaDeadline.value = DateTime.fromMillisecondsSinceEpoch( + endSeconds * 1000, + ); lastOnTime.value = currentSeconds <= endSeconds; lastLateBy.value = currentSeconds <= endSeconds ? Duration.zero @@ -770,6 +951,15 @@ class PickupsController extends GetxController final resp = await _updateProvider.updatePickup(payload); final ok = _isSuccess(resp); + // Reset first: a stale number from the previous stop shown on this one's + // success screen would be worse than none. + lastTrackingNo.value = ''; + if (ok && resp?['details'] is Map) { + final details = resp!['details'] as Map; + lastTrackingNo.value = + (details['tracking_no'] ?? details['trackingno'] ?? '').toString(); + } + // Save current RIDER GPS location as last pickup location for next pickup if (ok) { final actualLat = ll['lat'] ?? '0'; @@ -871,6 +1061,18 @@ class PickupsController extends GetxController } // Helper method to show snackbar reliably in both debug and release builds + // + // ── It has to say what actually happened ── + // + // This took a title and a message, built neither into anything, and printed + // the string "Something went wrong" instead — every time, for every reason. + // The one case that matters most is the proximity block: the caller composes + // "You are 4,200 m from the stop, you need to be within 100 m", and the rider + // was shown three words that told him nothing and implied a bug in the app + // rather than a thing he could fix by riding to the address. + // + // The message is now the message. The title is still only for the log — the + // snackbar is one line and a heading above it would push the fact off it. void _showErrorSnackbar( String title, String message, { @@ -884,15 +1086,10 @@ class PickupsController extends GetxController return; } + debugPrint('[GEOFENCE] $title: $message'); + try { - final context = Get.key.currentContext; - - // Get bottom safe area padding - final double bottomSafePadding = context != null - ? MediaQuery.of(context).padding.bottom - : 0; - - AppFeedback.errorGlobal('Something went wrong'); + AppFeedback.errorGlobal(message); } catch (e) { debugPrint( '[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e', @@ -903,7 +1100,7 @@ class PickupsController extends GetxController Future.delayed(const Duration(milliseconds: 400), () { try { - AppFeedback.errorGlobal('Something went wrong'); + AppFeedback.errorGlobal(message); } catch (e2) { debugPrint('[GEOFENCE] Retry snackbar failed: $e2'); @@ -923,6 +1120,24 @@ class PickupsController extends GetxController } } + /// Why the last status update refused to run, when it refused for a reason + /// the rider can act on — today that is only the proximity check. Null when + /// nothing blocked it. + /// + /// ── Refused is not failed ── + /// + /// [updatePickedupStatus] and friends return `false` for two very different + /// events: the server said no / the request never left the phone, and *the + /// app itself declined to send it* because the rider is not at the address. + /// Callers treat a `false` of the first kind optimistically — the stop is + /// finished locally and the rider moves on, because a dead network must not + /// strand him at a door. Doing that for the second kind is how a stop the hub + /// was never told about ends up on the success screen. + /// + /// Set immediately before the refusal, cleared at the top of every update, so + /// a caller can read it straight after its `await`. + String? lastBlockedReason; + Future _checkGeofence( double targetLat, double targetLng, @@ -930,12 +1145,21 @@ class PickupsController extends GetxController double currentLng, String action, ) async { - // Testing/demo bypass: skip proximity enforcement so mock stops far from - // the rider can still be completed. Remove/flip for production. + lastBlockedReason = null; + + // Opt-in via `--dart-define=BYPASS_GEOFENCE=true`; enforced otherwise. See + // the declaration for why it is a define rather than a constant. + // + // Logged with `debugPrint` in *every* build mode, deliberately — not behind + // `kDebugMode` like the diagnostics below. The whole risk of this flag is a + // build going out with proximity silently off, so the one thing it must + // never be is quiet. if (kBypassGeofenceForTesting) { - if (kDebugMode) { - debugPrint('[GEOFENCE] Bypassed for $action (testing flag on)'); - } + debugPrint( + '[GEOFENCE] OFF for $action — enforcement is disabled in this build. ' + 'Stop completion is NOT proximity-verified. ' + 'See kGeofenceEnforced.', + ); return true; } @@ -988,12 +1212,18 @@ class PickupsController extends GetxController } if (distance > radius) { - // Show error snackbar with clear message using helper method - _showErrorSnackbar( - 'Location Error', - 'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m', - seconds: 5, - ); + // ── One sentence, in metres he can act on ── + // + // Was three lines of "Distance: 4213 m (4.21 km) / Required: Within + // 100 m" — a readout, in a snackbar, on a phone in a jacket pocket. + // What the rider needs is how far he still has to go. + final String away = distanceMeters >= 1000 + ? '${distanceKm.toStringAsFixed(1)} km' + : '${distanceMeters.toStringAsFixed(0)} m'; + lastBlockedReason = + "You're $away from this stop — get within $radius m to mark it " + '${action.toLowerCase()}'; + _showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5); return false; } return true; @@ -1166,6 +1396,9 @@ class PickupsController extends GetxController String ridersLng = '0', String notes = '', }) async { + final mock = _mockStatus(pickupId, 'accepted'); + if (mock != null) return mock; + try { final ll = await _ensureLatLng(ridersLat, ridersLng); final acceptedTime = _formatDateTimeFull(DateTime.now()); @@ -1213,6 +1446,9 @@ class PickupsController extends GetxController String notes = '', String orderId = '', }) async { + final mock = _mockStatus(pickupId, 'active'); + if (mock != null) return mock; + try { final now = DateTime.now(); _activeTime = _formatDateTimeFull(now); @@ -1342,6 +1578,29 @@ class PickupsController extends GetxController } // Arrived + /// The consignment state the last `pickup-complete` reported, as a + /// [ConsignmentState] name. Empty when the response named none. + /// + /// Read by Home to stamp the rung the **server** produced rather than the + /// one this build expects. See [updatePickedStatus]. + String lastPivotConsignmentStatus = ''; + + /// True when that pivot released the consignment itself — the backend's + /// compatibility mode, and the reason Admin shows Active for a fresh pickup. + bool lastPivotCompatibilityMode = false; + + /// The server's own instruction for what comes next, e.g. `start_delivery`. + String lastPivotNextAction = ''; + + /// Whether the **server** confirmed the last arrival, not whether the call + /// returned 200. See [updateArrivedStatus]. + final RxBool arrivalConfirmedByServer = true.obs; + + /// What to tell the rider when it did not. Null when there is nothing to + /// say, which is the state this should be in once the backend ships a + /// `reached` that writes `Arrived_At_Pickup`. + String? lastArrivalNotice; + Future updateArrivedStatus({ required int pickupId, required int orderHeaderId, @@ -1351,6 +1610,9 @@ class PickupsController extends GetxController String pickupLng = '0', String notes = '', }) async { + final mock = _mockStatus(pickupId, 'arrived'); + if (mock != null) return mock; + // START LOADING IMMEDIATELY for better UX arrivedShimmer.value = true; try { @@ -1398,8 +1660,34 @@ class PickupsController extends GetxController if (!ok) { debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}'); + return false; } - return ok; + + // ── Succeeded, but did the hub record it? ── + // + // Two different answers, and this used to return the first as if it were + // the second. `reached` answers 200 `success: true` and leaves the + // booking status untouched (verified in production 21 Aug 2026), so the + // rider saw ARRIVED and the office saw nothing — for as long as that + // endpoint stays a no-op, which is not something the app can fix. + // + // What the app can do is stop asserting the agreement. The rung still + // advances, because a rider standing at a kitchen cannot be blocked by + // somebody else's deployment; but it advances as *his* record, and the + // discrepancy is named rather than hidden behind a tick. + arrivalConfirmedByServer.value = resp?['confirmed'] == true; + if (!arrivalConfirmedByServer.value) { + lastArrivalNotice = + 'Marked arrived on your phone. Your hub has not recorded it — ' + 'their system is not accepting arrivals yet.'; + debugPrint( + '[UPDATE][ARRIVED][UNCONFIRMED] server said ' + '"${resp?['serverstatus']}" — ${resp?['evidence']}', + ); + } else { + lastArrivalNotice = null; + } + return true; } catch (e) { debugPrint('[UPDATE][ARRIVED][ERROR] $e'); arrivedShimmer.value = false; @@ -1407,6 +1695,185 @@ class PickupsController extends GetxController } } + // ═══════════════════════════════════════════════════════════════════════ + // THE MILK RUN'S DELIVERY LEG + // + // Deliberately separate methods rather than a flag on the pickup ones. The + // pickup path measures rider kilometres, awards a punctuality bonus, saves a + // "last pickup location" for the next leg's distance and recomputes + // chargeable weight — none of which describes handing somebody a lunch. A + // boolean threading through all of that would make both jobs harder to read + // and put the collection path, which every logistics rider depends on, one + // typo away from a milk-run change. + // + // What they DO share is the geofence, the location fix and the provider, so + // those are reused as-is. + // ═══════════════════════════════════════════════════════════════════════ + + /// The rider has reached a **customer's** door on his round. + /// + /// **BACKEND DEPENDENCY — this writes nothing.** There is no route that + /// records arrival at a delivery address: `/bookings/:id/reached` belongs to + /// the pickup phase and the consignment routes have no arrival event. So this + /// verifies he is actually there and returns — the rung exists because the + /// rider needs it (it is what turns his Deliver button on at the right door), + /// not because the hub can see it. + /// + /// Returning true from a method that sent nothing would normally be a lie; + /// it is not one here because the method does not claim to have written. + /// See [MilkRun.deliveryArrivalIsLocalOnly]. + Future updateDeliveryArrived({ + required int pickupId, + String ridersLat = '0', + String ridersLng = '0', + String dropLat = '0', + String dropLng = '0', + }) async { + arrivedShimmer.value = true; + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(dropLat) ?? 0.0; + final dLng = double.tryParse(dropLng) ?? 0.0; + + // Fenced against the DROP, not the pickup. Using the pickup coordinates + // here would fence the rider to the kitchen he left an hour ago. + if (dLat != 0 && dLng != 0) { + final inFence = await _checkGeofence( + dLat, + dLng, + rLat, + rLng, + 'Delivery arrived', + ); + if (!inFence) return false; + } + + // The clock the completion record reads, same key the pickup leg uses. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'pickup_start_$pickupId', + DateTime.now().toIso8601String(), + ); + return true; + } catch (e) { + debugPrint('[DELIVERY][ARRIVED][ERROR] $e'); + return false; + } finally { + arrivedShimmer.value = false; + } + } + + /// Handed over. `POST /miler/consignments/:consignmentid/deliver`. + /// + /// Keyed on the **consignment**, which is why [consignmentId] is required and + /// not derived: it and the booking id come from different sequences, and the + /// route answers 404 for the wrong one while the rider is shown success — + /// the same trap `bookingassignmentid` sets on the accept side. + /// + /// No OTP: nothing generates one for a milk-run drop and the handler cannot + /// verify a code against anything, so sending a placeholder would be + /// fabricating proof. See [MilerApi.deliver]. + Future updateDeliveredStatus({ + required int pickupId, + required String consignmentId, + required String deliveredToName, + String ridersLat = '0', + String ridersLng = '0', + String dropLat = '0', + String dropLng = '0', + String notes = '', + String? proofImage, + }) async { + lastBlockedReason = null; + if (consignmentId.trim().isEmpty) { + debugPrint( + '[DELIVERED] no consignmentid for pickup $pickupId — refusing', + ); + // Not a wire failure: the app itself refused, and the caller's message + // should say what to do rather than suggest a retry. + lastBlockedReason = + 'This order was never picked up on the system — mark it picked ' + 'up first, then deliver.'; + return false; + } + + pickedShimmer.value = true; + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(dropLat) ?? 0.0; + final dLng = double.tryParse(dropLng) ?? 0.0; + + if (dLat != 0 && dLng != 0) { + final inFence = await _checkGeofence( + dLat, + dLng, + rLat, + rLng, + 'Delivered', + ); + if (!inFence) return false; + } + + _pickedTime = _formatDateTimeFull(DateTime.now()); + + // ── The coordinates must be the DOOR's ── + // + // The server computes `riderkms` for this leg by haversine from the + // pickup coordinates to whatever is sent here, and writes it onto the + // earnings record. Sending the pickup's own position would silently zero + // the rider's distance — and therefore his pay — for the whole leg. + final payload = { + 'pickupid': pickupId, + 'consignmentid': consignmentId.trim(), + 'orderstatus': 'delivered', + 'pickupcustomer': deliveredToName, + 'pickupedtime': _pickedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'pickuplat': dropLat, + 'pickuplong': dropLng, + 'notes': notes, + 'dropimage': proofImage ?? '', + }; + + final resp = await _updateProvider.updatePickup(payload); + final ok = _isSuccess(resp); + if (!ok) { + debugPrint('[DELIVERED][FAILED] resp=${jsonEncode(resp)}'); + // ── The server's refusal is the rider's message ── + // + // This swallowed the response and the UI fell back to "Could not + // record delivered — try again", which reads as a network blip and + // invites a retry that will refuse identically. The backend's message + // names the actual condition ("not out for delivery", "not found"), + // which is the difference between a rider retrying forever and a + // rider knowing what to tell the hub. + final serverMsg = (resp?['message'] ?? '').toString().trim(); + // ── `not out for delivery` is NOT a hub-release message ── + // + // It was mapped to "the hub hasn't released this yet", which is one of + // the two opposite situations the backend spells that way — the other + // is **already delivered**, which is what a rider actually meets. The + // question is now answered before the call, from the consignment's own + // history, so this branch no longer guesses: whatever the server says + // is passed through as the server's own words. + if (serverMsg.isNotEmpty && serverMsg.length < 140) { + lastBlockedReason = 'The office refused this delivery: $serverMsg'; + } + } + return ok; + } catch (e) { + debugPrint('[DELIVERED][ERROR] $e'); + return false; + } finally { + pickedShimmer.value = false; + } + } + // Rejected (remove from queue) Future updateRejectedStatus({ required int pickupId, @@ -1415,6 +1882,9 @@ class PickupsController extends GetxController String ridersLng = '0', String notes = '', }) async { + final mock = _mockStatus(pickupId, 'rejected'); + if (mock != null) return mock; + try { final ll = await _ensureLatLng(ridersLat, ridersLng); final payload = { @@ -1461,7 +1931,11 @@ class PickupsController extends GetxController double actualKms = 0.0, bool wasSkipped = false, }) async { + final mock = _mockStatus(pickupId, 'cancelled'); + if (mock != null) return mock; + try { + lastBlockedReason = null; final now = DateTime.now(); _cancelledTime = _formatDateTimeFull(now); @@ -1746,6 +2220,9 @@ class PickupsController extends GetxController String ridersLng = '0', String notes = '', }) async { + final mock = _mockStatus(pickupId, 'skipped'); + if (mock != null) return mock; + try { final ll = await _ensureLatLng(ridersLat, ridersLng); final skippedTime = _formatDateTimeFull(DateTime.now()); diff --git a/lib/controllers/rewards_controller.dart b/lib/controllers/rewards_controller.dart index 73ca99d..14c7222 100644 --- a/lib/controllers/rewards_controller.dart +++ b/lib/controllers/rewards_controller.dart @@ -29,8 +29,7 @@ class RewardsController extends GetxController { // earnings. final res = await MilerApi.earnings(period: 'monthly'); if (res.ok) { - totalPoints.value = - int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0; + totalPoints.value = int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0; } else { error.value = res.message.isEmpty ? "Couldn't load your points" diff --git a/lib/controllers/riderlog.dart b/lib/controllers/riderlog.dart index 1fff621..61bc29c 100644 --- a/lib/controllers/riderlog.dart +++ b/lib/controllers/riderlog.dart @@ -169,7 +169,6 @@ class RiderLogController extends GetxController required String latitude, required String longitude, }) async { - final payload = { "breakid": breakid, "logid": logid, @@ -471,7 +470,6 @@ class RiderLogController extends GetxController payload.remove('lastname'); } - await _saveToOfflineQueue('/miler/logs', payload); } } catch (e) { @@ -488,15 +486,6 @@ class RiderLogController extends GetxController final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); 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 payload = RiderUpdate( userid: userid, @@ -898,7 +887,6 @@ class RiderLogController extends GetxController final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 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 final payload = { "breakid": localBreakId, @@ -1074,7 +1062,6 @@ class RiderLogController extends GetxController final iso = _formatDateTimeFull(now); final loginTime = _formatTime(now); - // ✅ Check if there are active pickup to set status final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final String riderStatus = hasActivePickups ? 'active' : 'idle'; diff --git a/lib/data/accepted_store.dart b/lib/data/accepted_store.dart index 704702d..5040e1c 100644 --- a/lib/data/accepted_store.dart +++ b/lib/data/accepted_store.dart @@ -1,12 +1,134 @@ +import 'package:flutter/foundation.dart'; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/data/service_day.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_scope.dart'; + +/// ── Every key in this file belongs to a session, not to the handset ── +/// +/// These stores held finished work, skipped work, carried bags and released +/// orders under *global* keys — `completed_bookings` and friends — so one +/// phone had one drawer and whoever logged in last opened it. Logging out and +/// back in as another rider, or a tenant switch that moves the app between the +/// milk-man round and the logistics day, showed the previous scope's records +/// as if they were yours. +/// +/// The key now carries the identity the session can prove: rider, tenant and +/// line. See [WorkScope]. Nothing else in this file changed shape — the +/// scoping happens here, at the data boundary, once. +Future _scopedKey(String base) async => + (await WorkScope.current()).scoped(base); + +/// Legacy global keys, drained on first scoped access. +/// +/// ── Why draining and not migrating ── +/// +/// A legacy row cannot say whose it is: the global keys predate the identity +/// stamp, so a `completed_bookings` blob is *some* rider's, on *some* line, +/// and attributing it to whoever happens to log in first would be inventing +/// ownership — the exact leak this change exists to close. Rows that prove +/// their own ownership ([WorkScope.owns]) are carried across; the rest are +/// dropped. The cost is bounded and small: [getCompletedBookings] already +/// prunes to today, so at worst one day of local history is lost once, on one +/// upgrade, for records nobody can attribute anyway. The server-side history +/// is untouched by any of this. +Future _drainLegacy(String base, WorkScope scope) async { + final prefs = await SharedPreferences.getInstance(); + if (!prefs.containsKey(base)) return; + + final scopedKey = scope.scoped(base); + final Object? legacy = prefs.get(base); + + if (legacy is String && !prefs.containsKey(scopedKey)) { + // A JSON blob of records: keep only the rows that prove they are ours. + final rows = _decode(legacy); + final mine = [ + for (final r in rows) + if (scope.owns(r)) scope.stamp(r), + ]; + if (mine.isNotEmpty) { + await prefs.setString(scopedKey, jsonEncode(mine)); + } + } + // Id lists carry no identity at all, so there is nothing to carry across. + await prefs.remove(base); + debugPrint('[SCOPE] drained legacy "$base" into ${scope.key}'); +} + +/// Runs the one-time drain for every legacy key. Cheap after the first call — +/// `containsKey` on a loaded prefs map. +Future migrateLegacyStores() async { + final scope = await WorkScope.current(); + for (final base in const [ + _kAcceptedBookingsKeyBase, + _kRejectedOrderIdsKeyBase, + _kCompletedBookingsKeyBase, + _kSkippedBookingsKeyBase, + _kCollectedOrderIdsKeyBase, + _kConsignmentIdsKeyBase, + _kOutForDeliveryKeyBase, + _kNotLoadedKeyBase, + _kBagLabelsKeyBase, + ]) { + await _drainLegacy(base, scope); + } +} + +/// Seeds one of this scope's stores directly. **Tests only.** +/// +/// Fixtures used to write the bare global key (`completed_bookings`) because +/// that is what the store read. Now that a store belongs to a rider, a tenant +/// and a line, a fixture that writes the bare key is seeding a drawer nothing +/// opens — so it goes through the same resolver the app does, and the tests +/// exercise the shipped key scheme rather than a retired one. +/// [value] is the JSON blob for a record store, or the id list for one of the +/// set-shaped stores (`collected_order_ids`, `out_for_delivery_order_ids`, +/// `mock_rejected_order_ids`) — the same two shapes the stores themselves use. +/// The key [debugSeedStore] writes to, for assertions that read prefs back. +@visibleForTesting +Future debugStoreKey(String base) => _scopedKey(base); + +@visibleForTesting +Future debugSeedStore(String base, Object value) async { + final prefs = await SharedPreferences.getInstance(); + final key = await _scopedKey(base); + if (value is List) { + await prefs.setStringList(key, [for (final v in value) v.toString()]); + } else { + await prefs.setString(key, value.toString()); + } +} + +/// Wipes **this scope's** stores. Called on logout so the next rider on this +/// handset starts empty — see `AuthController`. +Future clearScopedStores() async { + final prefs = await SharedPreferences.getInstance(); + final scope = await WorkScope.current(); + for (final base in const [ + _kAcceptedBookingsKeyBase, + _kRejectedOrderIdsKeyBase, + _kCompletedBookingsKeyBase, + _kSkippedBookingsKeyBase, + _kCollectedOrderIdsKeyBase, + _kConsignmentIdsKeyBase, + _kOutForDeliveryKeyBase, + _kNotLoadedKeyBase, + _kBagLabelsKeyBase, + ]) { + await prefs.remove(scope.scoped(base)); + await prefs.remove(base); + } + debugPrint('[SCOPE] cleared ${scope.key}'); +} + /// Persistent store of bookings the rider has accepted while the app runs on /// mock data (offline / demo mode). This lets the accept flow be functional /// without a server: the Home queue drops accepted bookings (and keeps them /// dropped across refetches/navigation), and the Bookings tab picks them up. -const String _kAcceptedBookingsKey = 'mock_accepted_bookings'; -const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids'; +const String _kAcceptedBookingsKeyBase = 'mock_accepted_bookings'; +const String _kRejectedOrderIdsKeyBase = 'mock_rejected_order_ids'; /// Stops finished today, for the Activity tab. /// @@ -26,7 +148,7 @@ const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids'; /// /// This is the record that survives that. It is the same trade the accepted /// store already makes, for the same reason. -const String _kCompletedBookingsKey = 'completed_bookings'; +const String _kCompletedBookingsKeyBase = 'completed_bookings'; /// Stops the rider parked mid-shift for a return visit. /// @@ -43,8 +165,8 @@ const String _kCompletedBookingsKey = 'completed_bookings'; /// /// So a skip is recorded here the moment it is taken, with the reason the rider /// gave, and it is removed when he resumes the stop. Same trade as -/// [_kCompletedBookingsKey], for the same reason. -const String _kSkippedBookingsKey = 'skipped_bookings'; +/// [await _scopedKey(_kCompletedBookingsKeyBase)], for the same reason. +const String _kSkippedBookingsKeyBase = 'skipped_bookings'; List> _decode(String? raw) { if (raw == null || raw.isEmpty) return []; @@ -64,7 +186,7 @@ List> _decode(String? raw) { /// `orderstatus: 'accepted'`). Future>> getAcceptedBookings() async { final prefs = await SharedPreferences.getInstance(); - return _decode(prefs.getString(_kAcceptedBookingsKey)); + return _decode(prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase))); } /// The set of order ids that have been locally accepted. @@ -80,7 +202,9 @@ Future> getAcceptedOrderIds() async { /// rejected booking leaves the pending list. Future> getRejectedOrderIds() async { final prefs = await SharedPreferences.getInstance(); - return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet(); + return (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? + []) + .toSet(); } /// Remember the given order ids as rejected. @@ -88,9 +212,14 @@ Future addRejectedOrderIds(List ids) async { final clean = ids.where((s) => s.isNotEmpty).toSet(); if (clean.isEmpty) return; final prefs = await SharedPreferences.getInstance(); - final existing = (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet(); + final existing = + (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? []) + .toSet(); existing.addAll(clean); - await prefs.setStringList(_kRejectedOrderIdsKey, existing.toList()); + await prefs.setStringList( + await _scopedKey(_kRejectedOrderIdsKeyBase), + existing.toList(), + ); } /// Undo a rejection. @@ -102,10 +231,14 @@ Future removeRejectedOrderIds(List ids) async { final drop = ids.where((s) => s.isNotEmpty).toSet(); if (drop.isEmpty) return; final prefs = await SharedPreferences.getInstance(); - final remaining = (prefs.getStringList(_kRejectedOrderIdsKey) ?? []) - .where((id) => !drop.contains(id)) - .toList(); - await prefs.setStringList(_kRejectedOrderIdsKey, remaining); + final remaining = + (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? []) + .where((id) => !drop.contains(id)) + .toList(); + await prefs.setStringList( + await _scopedKey(_kRejectedOrderIdsKeyBase), + remaining, + ); } /// Remove the given order ids from the accepted store. Called when a pickup is @@ -116,9 +249,12 @@ Future removeAcceptedBookings(List ids) async { if (drop.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final remaining = _decode( - prefs.getString(_kAcceptedBookingsKey), + prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)), ).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList(); - await prefs.setString(_kAcceptedBookingsKey, jsonEncode(remaining)); + await prefs.setString( + await _scopedKey(_kAcceptedBookingsKeyBase), + jsonEncode(remaining), + ); } /// Stops the rider finished **today**, newest first. @@ -128,7 +264,9 @@ Future removeAcceptedBookings(List ids) async { /// tab — and the store cannot grow without bound. Future>> getCompletedBookings() async { final prefs = await SharedPreferences.getInstance(); - final all = _decode(prefs.getString(_kCompletedBookingsKey)); + final all = _decode( + prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)), + ); final today = _dayStamp(DateTime.now()); final mine = all.where((b) => (b['completedday'] ?? '') == today).toList() @@ -140,7 +278,10 @@ Future>> getCompletedBookings() async { // Prune in the background if yesterday's rows are still in there. if (mine.length != all.length) { - await prefs.setString(_kCompletedBookingsKey, jsonEncode(mine)); + await prefs.setString( + await _scopedKey(_kCompletedBookingsKeyBase), + jsonEncode(mine), + ); } return mine; } @@ -169,19 +310,35 @@ String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId'; /// This is the last moment they are all true at once, so this is where they are /// written down and the keys are cleared. Same argument as [StopCompliance], /// which is stamped one call earlier for the same reason. +/// [terminalStatus] names what "finished" means on this line, and defaults to +/// the active profile's answer. +/// +/// A logistics stop ends at `picked` — collecting the parcel *is* the job. A +/// milk-run stop ends at `delivered`, and stamping `picked` on it would file +/// the crate as the finished work and report fifteen lunches complete at the +/// moment the rider left the kitchen. One store, two honest endings; see +/// [StopStatus.isTerminal]. Future addCompletedBookings( List> bookings, { bool cancelled = false, + String? terminalStatus, }) async { if (bookings.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final now = DateTime.now(); + final String done = ServiceProfile.active.deliversToCustomer + ? 'delivered' + : 'picked'; final Map> byId = { - for (final b in _decode(prefs.getString(_kCompletedBookingsKey))) + for (final b in _decode( + prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)), + )) (b['orderid'] ?? '').toString(): b, }; + final scope = await WorkScope.current(); + for (final b in bookings) { final id = (b['orderid'] ?? '').toString(); if (id.isEmpty) continue; @@ -189,7 +346,11 @@ Future addCompletedBookings( // Stamped so the same `stopStatusOf` test that drops a stop from Bookings // is the one that picks it up here — the two can never disagree about what // "done" means. - copy['orderstatus'] = cancelled ? 'cancelled' : 'picked'; + // `terminalStatus` is the caller naming the outcome exactly; `cancelled` + // is the older shorthand for "anything that was not a completion". The + // precise word wins, so a skipped delivery is filed as a skip rather than + // being flattened into a cancellation it was not. + copy['orderstatus'] = terminalStatus ?? (cancelled ? 'cancelled' : done); copy['completedat'] = now.toIso8601String(); copy['completedday'] = _dayStamp(now); @@ -205,11 +366,13 @@ Future addCompletedBookings( await prefs.remove(kStopArrivedKey(pickupId)); } - byId[id] = copy; + // Stamped with the session that produced it, so a row read back later + // can prove its own ownership even if the key scheme changes again. + byId[id] = scope.stamp(copy); } await prefs.setString( - _kCompletedBookingsKey, + await _scopedKey(_kCompletedBookingsKeyBase), jsonEncode(byId.values.toList()), ); } @@ -221,7 +384,9 @@ Future addCompletedBookings( /// no longer act on. Future>> getSkippedBookings() async { final prefs = await SharedPreferences.getInstance(); - final all = _decode(prefs.getString(_kSkippedBookingsKey)); + final all = _decode( + prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)), + ); final today = _dayStamp(DateTime.now()); final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList() @@ -232,11 +397,30 @@ Future>> getSkippedBookings() async { ); if (mine.length != all.length) { - await prefs.setString(_kSkippedBookingsKey, jsonEncode(mine)); + await prefs.setString( + await _scopedKey(_kSkippedBookingsKeyBase), + jsonEncode(mine), + ); } return mine; } +/// The set of order ids finished today — picked up or written off. +/// +/// The queue endpoints are a poll or more behind the rider, so this is the only +/// thing that knows a stop is done the moment he says so. Home stamps it over +/// whatever status the queue is still reporting — see `_fetchQueues` — the same +/// way it already does for skips, and for the same reason: a card that keeps +/// saying LIVE after the rider has closed the stop reads as the app not having +/// heard him. +Future> getCompletedOrderIds() async { + final list = await getCompletedBookings(); + return list + .map((b) => (b['orderid'] ?? '').toString()) + .where((s) => s.isNotEmpty) + .toSet(); +} + /// The set of order ids currently parked as skipped. Future> getSkippedOrderIds() async { final list = await getSkippedBookings(); @@ -258,7 +442,9 @@ Future addSkippedBooking( final now = DateTime.now(); final Map> byId = { - for (final b in _decode(prefs.getString(_kSkippedBookingsKey))) + for (final b in _decode( + prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)), + )) (b['orderid'] ?? '').toString(): b, }; @@ -271,7 +457,10 @@ Future addSkippedBooking( copy['skippedday'] = _dayStamp(now); byId[id] = copy; - await prefs.setString(_kSkippedBookingsKey, jsonEncode(byId.values.toList())); + await prefs.setString( + await _scopedKey(_kSkippedBookingsKeyBase), + jsonEncode(byId.values.toList()), + ); } /// Forget a skip — the rider resumed the stop, so it is live work again. @@ -280,21 +469,29 @@ Future removeSkippedBookings(List ids) async { if (drop.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final remaining = _decode( - prefs.getString(_kSkippedBookingsKey), + prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)), ).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList(); - await prefs.setString(_kSkippedBookingsKey, jsonEncode(remaining)); + await prefs.setString( + await _scopedKey(_kSkippedBookingsKeyBase), + jsonEncode(remaining), + ); } -String _dayStamp(DateTime t) => - '${t.year}-${t.month.toString().padLeft(2, '0')}-' - '${t.day.toString().padLeft(2, '0')}'; +/// The local calendar date a record belongs to. +/// +/// Delegates to [ServiceDay] so the stamp written here and the day Activity +/// asks for are produced by the same line of code — two implementations of a +/// date format is how a shift ends up half in one day and half in the next. +String _dayStamp(DateTime t) => ServiceDay.stamp(t); /// Persist the given bookings as accepted, deduped by `orderid`. Future addAcceptedBookings(List> bookings) async { if (bookings.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final Map> byId = { - for (final b in _decode(prefs.getString(_kAcceptedBookingsKey))) + for (final b in _decode( + prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)), + )) (b['orderid'] ?? '').toString(): b, }; for (final b in bookings) { @@ -305,7 +502,384 @@ Future addAcceptedBookings(List> bookings) async { byId[id] = copy; } await prefs.setString( - _kAcceptedBookingsKey, + await _scopedKey(_kAcceptedBookingsKeyBase), jsonEncode(byId.values.toList()), ); } + +// ───────────────────────────────────────────────────────────────────────── +// COLLECTED — parcels that are physically in the rider's hands +// +// A meal run has a state the parcel backend cannot express. Its ladder is +// `accepted → arrived → picked`, and `picked` is terminal: it clears the order +// out of the accepted store and files it on Activity as finished. That is the +// right shape for a parcel, where collecting it from the customer IS the job. +// +// It is the wrong shape for a service route, where collecting is the *middle*. +// A rider loads ten lunches at a kitchen at 11:50 and delivers the last one at +// 13:20; writing `picked` at the kitchen would report all ten orders complete +// ninety minutes before anybody ate, and DailyGrubs is billed on that record. +// +// So `picked` stays where it belongs — the hand-over at the customer's door — +// and the intermediate state lives here, on the device, as the set of orders +// the rider is carrying. It is what moves a card off Home and onto Bookings. +// +// BACKEND DEPENDENCY: this is a local stand-in. The hub cannot see that a +// rider has loaded a kitchen until the API grows a `collected` status (and a +// `delivered` one, so the terminal event can be named for what it is). Until +// then a crash between the kitchen and the first door loses only the ordering, +// not the work: every order is still accepted server-side and still appears. +const String _kCollectedOrderIdsKeyBase = 'collected_order_ids'; + +/// Order ids the rider has loaded and is carrying. +Future> getCollectedOrderIds() async { + final prefs = await SharedPreferences.getInstance(); + return (prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ?? + []) + .toSet(); +} + +/// Records a load. Called once per kitchen, with everything taken from it. +Future addCollectedOrderIds(List ids) async { + final clean = ids.where((s) => s.isNotEmpty).toSet(); + if (clean.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final existing = + (prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ?? []) + .toSet(); + existing.addAll(clean); + await prefs.setStringList( + await _scopedKey(_kCollectedOrderIdsKeyBase), + existing.toList(), + ); +} + +/// ── The consignment id, captured at the pivot ── +/// +/// `pickup-complete` is the call that *creates* the consignment, and its id is +/// the key every delivery action needs: `deliver` and `skip` are consignment +/// routes, not booking ones, and passing a booking id gets a 404 while the +/// rider is shown success. +/// +/// That id was being thrown away. The response was wrapped into a legacy +/// envelope and discarded, and the row the rider then worked from on Deliveries +/// is a snapshot taken *before* the conversion — so it had no consignment id +/// either. The result: he collected an order, drove it to the door, pressed +/// **Delivered**, and the app refused because it did not know what to deliver. +/// +/// The queue does carry the id once the backend catches up, so this is a +/// bridge, not a second source of truth: [consignmentIdFor] prefers the row and +/// falls back to what was recorded here. +const String _kConsignmentIdsKeyBase = 'consignment_ids_by_order'; + +/// Records the consignment `pickup-complete` just created for [orderId]. +Future rememberConsignmentId(String orderId, String consignmentId) async { + if (orderId.isEmpty || consignmentId.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final map = await getConsignmentIds(); + map[orderId] = consignmentId; + await prefs.setString( + await _scopedKey(_kConsignmentIdsKeyBase), + jsonEncode(map), + ); +} + +/// Every consignment id this device recorded at a pivot, by order id. +Future> getConsignmentIds() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(await _scopedKey(_kConsignmentIdsKeyBase)); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return { + for (final e in decoded.entries) + e.key.toString(): e.value?.toString() ?? '', + }..removeWhere((_, v) => v.isEmpty); + } catch (_) { + return {}; + } +} + +/// Drops ids for orders that are finished, so the map does not grow for the +/// life of the install. +Future forgetConsignmentIds(List orderIds) async { + if (orderIds.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final map = await getConsignmentIds(); + var changed = false; + for (final id in orderIds) { + if (map.remove(id) != null) changed = true; + } + if (changed) + await prefs.setString( + await _scopedKey(_kConsignmentIdsKeyBase), + jsonEncode(map), + ); +} + +/// Clears ids once they are delivered — or once a short pick says they were +/// never in the box to begin with. Without this the set grows for the life of +/// the install and yesterday's run keeps today's cards off Home. +Future removeCollectedOrderIds(List ids) async { + final drop = ids.where((s) => s.isNotEmpty).toSet(); + if (drop.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final remaining = + (prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ?? []) + .where((id) => !drop.contains(id)) + .toList(); + await prefs.setStringList( + await _scopedKey(_kCollectedOrderIdsKeyBase), + remaining, + ); +} + +// ───────────────────────────────────────────────────────────────────────── +// OUT FOR DELIVERY — the load has been released and the round has begun +// +// ── Why this is separate from `collected` ── +// +// Collected means "in my hands". It is written the moment a source hands over +// a crate, and on a two-kitchen morning the rider is collected-but-not-driving +// for the better part of an hour. +// +// If collection alone opened the delivery list, his round would build itself +// underneath him: he finishes kitchen one, five drops appear, he sets off, and +// kitchen two's five arrive behind him — a route he has already half-driven +// past. So the round is held until the whole load is aboard and then released +// in one gesture, which is what the rider means when he presses START DELIVERY. +// +// That press is also a real server event — `POST /miler/deliveries/start` moves +// the consignments to `Out_for_Delivery`, which is the state the delivery route +// requires — so this set is a mirror of a server fact, not a substitute for +// one. It exists because the queue endpoints are a poll behind the rider and he +// must not watch his round appear late. +const String _kOutForDeliveryKeyBase = 'out_for_delivery_order_ids'; + +/// Order ids the rider is actively delivering. +Future> getOutForDeliveryOrderIds() async { + final prefs = await SharedPreferences.getInstance(); + return (prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? []) + .toSet(); +} + +/// Releases the round. Called once, with the whole load, after the server has +/// confirmed the transition. +Future addOutForDeliveryOrderIds(List ids) async { + final clean = ids.where((s) => s.isNotEmpty).toSet(); + if (clean.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final existing = + (prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? []) + .toSet(); + existing.addAll(clean); + await prefs.setStringList( + await _scopedKey(_kOutForDeliveryKeyBase), + existing.toList(), + ); +} + +/// Clears ids once they are delivered. Without this the set grows for the life +/// of the install and yesterday's round keeps today's cards on the delivery +/// list — the same trap [removeCollectedOrderIds] exists to avoid. +Future removeOutForDeliveryOrderIds(List ids) async { + final drop = ids.where((s) => s.isNotEmpty).toSet(); + if (drop.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final remaining = + (prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? []) + .where((id) => !drop.contains(id)) + .toList(); + await prefs.setStringList( + await _scopedKey(_kOutForDeliveryKeyBase), + remaining, + ); +} + +// ───────────────────────────────────────────────────────────────────────── +// NOT LOADED — orders the kitchen could not supply +// +// Nine bags where the manifest said ten. The tenth is not skipped (nobody was +// visited), not cancelled (the customer did nothing wrong) and not delivered — +// it never entered the rider's box, and the only useful thing the app can do is +// take it off his route immediately and say why, rather than let him drive to a +// door at 12:50 for a meal that does not exist. +const String _kNotLoadedKeyBase = 'not_loaded_orders'; + +Future> getNotLoadedOrderIds() async { + final prefs = await SharedPreferences.getInstance(); + return (prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? []) + .toSet(); +} + +Future addNotLoadedOrderIds(List ids) async { + final clean = ids.where((s) => s.isNotEmpty).toSet(); + if (clean.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final existing = + (prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? []).toSet(); + existing.addAll(clean); + await prefs.setStringList( + await _scopedKey(_kNotLoadedKeyBase), + existing.toList(), + ); +} + +// ───────────────────────────────────────────────────────────────────────── +// BAG IDENTITY — which bag an order travels in, for as long as it travels +// +// ── Why this is stored rather than recomputed ── +// +// A bag number is the order's position in the load it came off — Joe is Bag 1 +// of the five that left Vidhya Kitchen. Recomputing that later from whatever +// list happens to be on screen is how identity breaks: deliver Joe, and a +// position-derived Arun silently becomes Bag 1. The rider is then looking for a +// bag labelled 1 that is in a customer's hallway. +// +// So the pairing is fixed once, at the counter, at the moment the manifest is +// confirmed — and read back unchanged through delivery, skip, completion and a +// day of intermittent signal. See [BagManifest], which derives it; this only +// remembers what it derived. +// ───────────────────────────────────────────────────────────────────────── +const String _kBagLabelsKeyBase = 'order_bag_labels'; + +/// Remembers which bag each order travels in. Merges, never replaces: a rider +/// works two kitchens and the second load must not erase the first. +Future saveBagLabels(Map byOrderId) async { + final clean = { + for (final e in byOrderId.entries) + if (e.key.trim().isNotEmpty && e.value.trim().isNotEmpty) + e.key.trim(): e.value.trim(), + }; + if (clean.isEmpty) return; + + final prefs = await SharedPreferences.getInstance(); + final merged = {...await getBagLabels(), ...clean}; + // Stored as `id\u0000label` rows: SharedPreferences has no map type, and a + // NUL separator cannot collide with an order id or a bag label. + await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), [ + for (final e in merged.entries) '${e.key}\u0000${e.value}', + ]); +} + +/// The bag each order is in, as recorded at pickup. Empty before any load. +Future> getBagLabels() async { + final prefs = await SharedPreferences.getInstance(); + final rows = + prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ?? + const []; + return { + for (final row in rows) + if (row.contains('\u0000')) + row.split('\u0000').first: row.split('\u0000').last, + }; +} + +/// Clears every trace of a day's service run. +/// +/// Both sets above are keyed by order id with no date on them, so without this +/// a rider who never finished yesterday's last drop would find today's Home +/// quietly hiding an unrelated order that happened to reuse the id. +Future clearServiceRunState() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(await _scopedKey(_kCollectedOrderIdsKeyBase)); + await prefs.remove(await _scopedKey(_kOutForDeliveryKeyBase)); + await prefs.remove(await _scopedKey(_kNotLoadedKeyBase)); + await prefs.remove(await _scopedKey(_kBagLabelsKeyBase)); +} + +// ───────────────────────────────────────────────────────────────────────── +// PURGING THE OLD DEMO LAYER OFF A DEVICE +// +// The mock data is gone from the source, and that is not enough. Every store +// above is SharedPreferences, and a phone that ran a build with the demo layer +// still has those rows on it — the accepted-store key is literally +// `mock_accepted_bookings`. Bookings merges the accepted store into its list +// and Activity reads the completed and skipped ones, so the demo stops keep +// appearing on exactly those two tabs with nothing in the repo producing them. +// Deleting the generator cannot reach data it already wrote. +// +// So this runs once at startup and drops anything the old layer left behind. +// It is keyed on the ids that layer used — `MOCK-Q-1001`, `del-mock-d-2003` — +// which is safe because a real order id comes from the backend and has never +// looked like that. A blanket wipe would take the rider's genuine accepted +// bookings with it. +const List _demoIdMarkers = ['mock-', 'demo-']; + +bool _looksLikeDemoRecord(Map booking) { + for (final key in const ['orderid', 'pickupid', 'orderheaderid']) { + final v = (booking[key] ?? '').toString().toLowerCase(); + if (v.isEmpty) continue; + for (final marker in _demoIdMarkers) { + if (v.startsWith(marker) || v.contains('-$marker')) return true; + } + } + return false; +} + +bool _looksLikeDemoId(String id) { + final v = id.toLowerCase(); + return _demoIdMarkers.any((m) => v.startsWith(m) || v.contains('-$m')); +} + +/// Removes every record the retired demo layer wrote, from all six stores. +/// +/// Returns how many were dropped, so a one-off cleanup is visible in the log +/// rather than being a silent mutation of the rider's data. +Future purgeDemoRecords() async { + final prefs = await SharedPreferences.getInstance(); + var dropped = 0; + + for (final key in [ + await _scopedKey(_kAcceptedBookingsKeyBase), + await _scopedKey(_kCompletedBookingsKeyBase), + await _scopedKey(_kSkippedBookingsKeyBase), + ]) { + final existing = _decode(prefs.getString(key)); + if (existing.isEmpty) continue; + final kept = existing.where((b) => !_looksLikeDemoRecord(b)).toList(); + if (kept.length != existing.length) { + dropped += existing.length - kept.length; + await prefs.setString(key, jsonEncode(kept)); + } + } + + for (final key in [ + await _scopedKey(_kRejectedOrderIdsKeyBase), + await _scopedKey(_kCollectedOrderIdsKeyBase), + await _scopedKey(_kOutForDeliveryKeyBase), + await _scopedKey(_kNotLoadedKeyBase), + ]) { + final existing = prefs.getStringList(key) ?? const []; + if (existing.isEmpty) continue; + final kept = existing.where((id) => !_looksLikeDemoId(id)).toList(); + if (kept.length != existing.length) { + dropped += existing.length - kept.length; + await prefs.setStringList(key, kept); + } + } + + // The bag map is keyed by order id, so it needs the same sweep — otherwise a + // demo day leaves `MOCK-M-1001 → Bag 1` behind for the life of the install. + // Harmless on its own, but this store exists to be the one place that knows + // which bag an order is in, and a stale entry is exactly the kind of thing + // that is trusted later precisely because it is stored. + final bags = + prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ?? + const []; + if (bags.isNotEmpty) { + final kept = bags + .where((row) => !_looksLikeDemoId(row.split('\u0000').first)) + .toList(); + if (kept.length != bags.length) { + dropped += bags.length - kept.length; + await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), kept); + } + } + + if (dropped > 0) { + debugPrint('[STORE] purged $dropped demo record(s) left by an old build'); + } + return dropped; +} diff --git a/lib/data/api_config.dart b/lib/data/api_config.dart index 9e3c987..52bd24d 100644 --- a/lib/data/api_config.dart +++ b/lib/data/api_config.dart @@ -1,4 +1,10 @@ +import 'dart:convert'; + 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'; /// Base URL, bearer token, and the adapter that turns a v1 booking into the @@ -59,6 +65,62 @@ class ApiConfig { 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 storedTenantId() async => + tenantIdFromToken(await getToken()); + // --------------------------------------------------------------------------- // Response envelope adapter: {success,data,message} -> {status,details} // --------------------------------------------------------------------------- @@ -94,26 +156,95 @@ class ApiConfig { // / picked / skipped / cancelled / rejected static String legacyStatusFromNew(String? newStatus) { - switch ((newStatus ?? '').trim()) { - case 'Miler_Assigned': - // Admin-assigned but NOT yet accepted by the rider. This must land on - // the Home tab as a pending booking to accept/reject — it becomes - // 'accepted' (a client-side marker in accepted_store) only once the - // rider accepts it, which is what moves it to the Bookings tab. - return 'assigned'; - case 'Pickup_Scheduled': - return 'active'; - case 'At_Customer': - return 'arrived'; - case 'Picked_Up': - return 'Picked up'; - case 'Converted_To_Consignment': - return 'picked'; - case 'Cancelled': - return 'cancelled'; - default: - return newStatus ?? ''; - } + // ── Parsed, not string-matched ── + // + // This was a `switch` on raw strings whose `default` returned the value + // unchanged, so two things went wrong quietly. `Pending_Pickup` and + // `Created` were not listed at all and fell through as themselves, and a + // status added by the backend tomorrow would do the same — arriving in the + // UI as an unrecognised string that the row logic then had to guess at. + // + // [BookingStatus] covers the contract exhaustively and folds everything + // else into `unknown`, which maps to the empty string here: a stop the app + // cannot classify renders as undecided, never as picked, cancelled or + // otherwise finished. Wrong-but-safe beats wrong-and-settled. + return switch (BookingStatus.parse(newStatus)) { + // Admin-assigned but NOT yet accepted by the rider. These land on Home as + // pending bookings to accept or reject; the client-side accepted marker + // is what moves one to the work tab. + BookingStatus.pendingPickup || + BookingStatus.created || + 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, // pickupcontactno, collectionamt, step, type ...). Translate a new `booking` // 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 sequenceFieldNames = RouteOrder.sequenceKeys; + static Map pickupFromBooking(Map booking) { String s(dynamic v) => v == null ? '' : v.toString(); // First non-null value among several candidate keys — makes the adapter @@ -168,6 +302,21 @@ class ApiConfig { 'orderstatus', '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 { // identity 'pickupid': id, @@ -177,7 +326,12 @@ class ApiConfig { 'bookingreference': s(ref), // 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 'pickupcustomer': s( @@ -201,6 +355,9 @@ class ApiConfig { 'pickupaddress': s( pick(['pickupaddress', 'pickupAddress', 'pickup_address']), ), + 'pickuppincode': s( + pick(['pickuppincode', 'pickupPincode', 'pickup_pincode']), + ), 'pickuplat': s( pick([ 'pickuplatitude', @@ -232,6 +389,9 @@ class ApiConfig { 'dropaddress': s( pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']), ), + 'droppincode': s( + pick(['deliverypincode', 'deliveryPincode', 'delivery_pincode']), + ), 'droplat': s( pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']), ), @@ -239,9 +399,80 @@ class ApiConfig { pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']), ), - // stop type — new bookings are first-mile PICKUPS; delivery legs come - // through the consignment flow. Backend has no per-stop `type` yet. - 'type': 'pickup', + // ── Where this stop is collected FROM ── + // + // 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']), + ), + + // ── 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, + + // ── 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) 'collectionamt': booking['collectionamt'] ?? 0, diff --git a/lib/data/api_status.dart b/lib/data/api_status.dart new file mode 100644 index 0000000..c2088b7 --- /dev/null +++ b/lib/data/api_status.dart @@ -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 = { + '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 = { + '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 = { + '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 = { + '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; +} diff --git a/lib/data/assignment_lookup.dart b/lib/data/assignment_lookup.dart index 9190351..3d78006 100644 --- a/lib/data/assignment_lookup.dart +++ b/lib/data/assignment_lookup.dart @@ -1,8 +1,6 @@ -import 'dart:convert'; - 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 /// endpoints key on. @@ -21,6 +19,27 @@ class AssignmentLookup { /// bookingid (as string) -> bookingassignmentid static final Map _cache = {}; + + /// 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 _steps = {}; + static DateTime? _fetchedAt; /// Assignments change whenever the hub assigns work, so the map goes stale @@ -41,9 +60,22 @@ class AssignmentLookup { static void invalidate() { _cache.clear(); + _steps.clear(); _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> steps() async { + if (_isStale) await _refresh(); + return Map.unmodifiable(_steps); + } + /// The assignment id for [bookingId], or null when the backend has no /// assignment row for it (or the call fails). /// @@ -67,22 +99,21 @@ class AssignmentLookup { static Future _refresh() async { try { - final uri = Uri.parse(ApiConfig.url('/miler/assignments')); - final res = await http - .get(uri, headers: await ApiConfig.authHeaders()) - .timeout(const Duration(seconds: 15)); - if (res.statusCode < 200 || res.statusCode >= 300) { - debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.statusCode}'); + // Through [MilerApi], not a hand-rolled `http.get`. It was the latter, + // which meant this one call carried its own header building, its own + // envelope unwrapping and its own idea of what a 2xx is — and, because it + // bypassed `MilerApi.client`, it was the only request in the app that a + // test could not stub, so the suite made real network calls to the + // production API while checking a repository. + final res = await MilerApi.assignments(); + if (!res.ok) { + debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.status}'); return; } - final decoded = json.decode(res.body); - dynamic data = decoded; - if (decoded is Map) { - data = decoded['data'] ?? decoded['details'] ?? decoded['assignments']; - } - if (data is! List) { - debugPrint('[ASSIGNMENTS] unexpected payload: ${data.runtimeType}'); + final List data = res.list; + if (data.isEmpty && res.data is! List) { + debugPrint('[ASSIGNMENTS] no assignment rows in the response'); return; } @@ -90,9 +121,14 @@ class AssignmentLookup { _cache ..clear() ..addAll(next); + final nextSteps = buildStepIndex(data); + _steps + ..clear() + ..addAll(nextSteps); _fetchedAt = DateTime.now(); debugPrint( - '[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids', + '[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids, ' + '${_steps.length} sequenced', ); } catch (e) { debugPrint('[ASSIGNMENTS] fetch error: $e'); @@ -131,6 +167,44 @@ class AssignmentLookup { 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 buildStepIndex(List rows) { + final index = {}; + final tookActionable = {}; + + for (final row in rows.whereType()) { + 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) { if (v is int) return v; if (v is num) return v.toInt(); diff --git a/lib/data/bag_manifest.dart b/lib/data/bag_manifest.dart new file mode 100644 index 0000000..55054f6 --- /dev/null +++ b/lib/data/bag_manifest.dart @@ -0,0 +1,124 @@ +import 'package:miler/data/milk_run.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE PICKUP ORDER = ONE BAG +/// +/// The rule the whole rider workflow rests on, in one file so it cannot be +/// stated two different ways on two screens. +/// +/// If a kitchen has five pickup orders, the rider is handed **five bags** — one +/// per order — and ends up with **five deliveries**, each carrying the bag it +/// arrived in: +/// +/// ``` +/// Order 1 → Bag 1 → Joe +/// Order 2 → Bag 2 → Arun +/// Order 3 → Bag 3 → Priya +/// ``` +/// +/// ── Why this is a file and not a `+ 1` at each call site ── +/// +/// Every screen in the pickup half of the app has to answer "how many bags?" +/// and "which bag is this?", and every screen that answered it independently +/// answered it differently: one showed a backend `Quantity` (an order's item +/// count, not a bag count), one showed a bare `baglabel` when the payload +/// happened to carry one and nothing at all when it did not, and the kitchen +/// heading counted "meals". A rider standing at a counter comparing "5 meals" +/// on his phone with four bags on the shelf has no way to tell which of the two +/// numbers is wrong. +/// +/// So the count is **derived from the orders**, always, and there is no second +/// quantity anywhere that can disagree with it. +/// +/// ── What is derived, and what is never invented ── +/// +/// • **The count** is `orders.length`. It cannot drift, because it is not +/// stored — a bag is what an order arrives in. +/// • **The identity** prefers the label the backend printed on the physical +/// bag ([stopBagLabel]) and falls back to the order's **position in its own +/// pickup group** — `Bag 1`, `Bag 2` — which is what a rider counting a +/// shelf actually uses. +/// +/// Nothing here fabricates a crate, a tote or a quantity the backend has not +/// sent. If a future contract ever puts more than one bag on an order, this is +/// the one file that changes. +/// ───────────────────────────────────────────────────────────────────────── +class BagManifest { + BagManifest._(); + + /// One line of a pickup manifest: the order, the customer it is for, and the + /// bag it travels in. + /// + /// Deliberately carries the stop itself: every caller that renders a manifest + /// also needs to act on the orders behind it, and pairing them here is what + /// stops a screen from rendering five lines and posting four ids. + static List forGroup(List> stops) => [ + for (var i = 0; i < stops.length; i++) + BagLine( + stop: stops[i], + orderId: MilkRun.idOf(stops[i]), + customer: customerOf(stops[i]), + bag: _bagFor(stops[i], i), + ), + ]; + + /// The bag one order travels in, given the group it was collected with. + /// + /// [stops] must be the whole pickup group in route order — the position in it + /// *is* the bag number when the backend has not printed one. + static String bagFor( + Map stop, + List> group, + ) { + final id = MilkRun.idOf(stop); + final index = group.indexWhere((s) => MilkRun.idOf(s) == id); + return _bagFor(stop, index < 0 ? 0 : index); + } + + static String _bagFor(Map stop, int index) { + final printed = stopBagLabel(stop); + return printed.isNotEmpty ? printed : 'Bag ${index + 1}'; + } + + /// `5 bags` — the load, in the unit the rider actually carries. + /// + /// It used to print `5 orders · 5 bags`: the two halves of the one-bag-per- + /// order rule side by side, as a check the rider could eyeball. On a device + /// that check cost the header the fact it exists for — a real kitchen name + /// plus `13 orders · 13 bags` pushed `11 to accept` off the row, and the + /// clause that truncated was the one that changes what he does next. + /// + /// One number survives, and it is the physical one: the rows underneath are + /// named `Bag 1 … Bag n` and a settled group says `n bags collected`, so + /// "bags" is the word this column already speaks. No information is lost — + /// the rule makes the counts identical — and the verifiable statement lives + /// where the verifying happens: the manifest list itself, one line per bag. + static String countLabel(int orders) { + return orders == 1 ? '1 bag' : '$orders bags'; + } + + /// The name the bag is going to, for a manifest line. + static String customerOf(Map stop) => + (stop['pickupcustomer'] ?? + stop['customername'] ?? + stop['tenantname'] ?? + '') + .toString() + .trim(); +} + +/// One row of a pickup manifest. See [BagManifest.forGroup]. +class BagLine { + final Map stop; + final String orderId; + final String customer; + final String bag; + + const BagLine({ + required this.stop, + required this.orderId, + required this.customer, + required this.bag, + }); +} diff --git a/lib/data/consignment_state.dart b/lib/data/consignment_state.dart new file mode 100644 index 0000000..ff89632 --- /dev/null +++ b/lib/data/consignment_state.dart @@ -0,0 +1,316 @@ +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. + bool get awaitsHub => + this == ConsignmentState.created || + this == ConsignmentState.inwardedAtHub || + this == ConsignmentState.tripsheetLoaded || + this == ConsignmentState.inTransit; + + /// 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; +} + +/// 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, + + /// 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 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 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 _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 = >[ + 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 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.isClosed) return DeliverGate.closed; + return DeliverGate.unknown; + } + + /// Convenience: read and classify in one call. + static Future gateOf(Object consignmentId) async => + gateFor(await stateOf(consignmentId)); +} diff --git a/lib/data/lifecycle.dart b/lib/data/lifecycle.dart new file mode 100644 index 0000000..471b7f1 --- /dev/null +++ b/lib/data/lifecycle.dart @@ -0,0 +1,276 @@ +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 data, List 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); + final confirmed = parsed == BookingStatus.arrivedAtPickup; + + return StateTransition( + outcome: confirmed + ? TransitionOutcome.confirmed + : TransitionOutcome.unconfirmed, + bookingStatus: parsed, + consignmentState: ConsignmentState.unknown, + evidence: raw.isEmpty + ? 'the response named no status at all' + : 'the response reported 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"', + ); + } + + /// 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}'); + } + } +} diff --git a/lib/data/load_state.dart b/lib/data/load_state.dart new file mode 100644 index 0000000..e39f25c --- /dev/null +++ b/lib/data/load_state.dart @@ -0,0 +1,100 @@ +/// ───────────────────────────────────────────────────────────────────────── +/// WHAT A SCREEN IS SHOWING, AS ONE VALUE +/// +/// Screens carried three or four independent booleans — `_firstLoadDone`, +/// `_fetching`, `_failed`, plus a list that might be empty — and every screen +/// combined them slightly differently. That is how a page ends up showing an +/// empty state during a refresh, or a spinner over stale data, or nothing at +/// all when a request fails. +/// +/// One value, and the combinations that cannot happen are unrepresentable. +/// +/// ── Empty is not a failure, and unavailable is neither ── +/// +/// Three answers a rider acts on differently, which were all `[]` before: +/// +/// * **[LoadEmpty]** — the hub has given him nothing today. Wait. +/// * **[LoadFailure]** — the request did not complete. Retry. +/// * **[LoadUnavailable]** — his line of work has no endpoint behind it. Neither +/// waiting nor retrying will help, and telling him to do either is a lie. +/// See `ServiceProfile.hasBookingsEndpoint`. +/// ───────────────────────────────────────────────────────────────────────── +library; + +/// Why a load did not produce data. Kept separate from the HTTP status because +/// the rider's next action is what differs, not the number. +enum LoadFailureKind { + /// No usable connection, a timeout, or a socket that died mid-flight. + offline, + + /// The session is over. The shell signs the rider out; the screen should not + /// offer a retry that cannot succeed. + unauthorized, + + /// Too many requests. Retrying immediately makes it worse. + rateLimited, + + /// The server answered and refused, or answered with something unreadable. + server; + + /// Whether offering "Try again" is honest. + bool get isRetryable => this != LoadFailureKind.unauthorized; +} + +/// The state of one screen's data. +sealed class LoadState { + const LoadState(); + + /// The data, when there is any. Null in every other state — so a screen that + /// forgets to handle a case renders nothing rather than stale content. + T? get valueOrNull => switch (this) { + LoadData(:final value) => value, + _ => null, + }; + + bool get isLoading => this is LoadLoading; +} + +/// First load, or a refresh with nothing to show yet. +class LoadLoading extends LoadState { + const LoadLoading(); +} + +/// Data arrived and there is something in it. +class LoadData extends LoadState { + final T value; + + /// True while a refresh is running behind data that is already on screen. + /// + /// The distinction the old booleans lost: a pull-to-refresh must not blank + /// the list it is refreshing. + final bool refreshing; + + const LoadData(this.value, {this.refreshing = false}); +} + +/// The request succeeded and the answer was "nothing today". +class LoadEmpty extends LoadState { + const LoadEmpty(); +} + +/// The request did not complete. +class LoadFailure extends LoadState { + final LoadFailureKind kind; + + /// The server's own sentence where it gave one — it is more useful than + /// anything this app can invent about a failure it did not cause. + final String message; + + const LoadFailure(this.kind, {this.message = ''}); +} + +/// There is no backend for this rider's line of work. +/// +/// Not an error and not an empty day: a capability the deployment does not have +/// yet. Retrying cannot fix it and neither can waiting. +class LoadUnavailable extends LoadState { + final String reason; + + const LoadUnavailable(this.reason); +} diff --git a/lib/data/meal_run_mock.dart b/lib/data/meal_run_mock.dart new file mode 100644 index 0000000..4e41ecc --- /dev/null +++ b/lib/data/meal_run_mock.dart @@ -0,0 +1,358 @@ +import 'package:flutter/foundation.dart'; + +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A MEAL DAY, WITH NO BACKEND BEHIND IT +/// +/// The meal line has no endpoints. The Doormile backend serves parcel bookings +/// and consignments and knows nothing about kitchens, crates or subscribers, and +/// building against invented URLs would produce a flow that compiles, demos, and +/// is wrong the day a real contract arrives. +/// +/// So the meal line runs on this: a complete day, held in memory, walked with +/// the real screens. Every status change the rider makes is recorded here +/// instead of being sent, which means the whole process — accept, arrive, load +/// the crate, deliver, skip, fail — can be designed, used and judged before a +/// single endpoint exists. +/// +/// ── Why this is not the demo layer that was deleted ── +/// +/// A mock layer was ripped out of this app for a good reason: it wrote rows into +/// the same SharedPreferences stores the real work uses, so demo stops surfaced +/// on a live rider's tabs weeks later, with nothing left in the repo to explain +/// them. Three rules keep this one from becoming that: +/// +/// 1. **It cannot reach a parcel rider.** [active] is false unless the signed-in +/// profile is the meal line, and an unrecognised tenant resolves to parcel. +/// There is no path from a normal login to this data. +/// 2. **Its ids are self-identifying.** Every id is `MOCK-…`, which is exactly +/// what `purgeDemoRecords()` already looks for — so anything that does leak +/// into a store is removed at the next launch, by code that already ships. +/// 3. **It has one switch.** [kMealMockEnabled] is the whole feature. When the +/// meal endpoints land, that constant goes false and the provider falls +/// through to the real call; nothing else in the app knows this file exists. +/// ───────────────────────────────────────────────────────────────────────── + +/// Whether the day below is served instead of calling the API. +/// +/// ── One switch for the whole app, not two ── +/// +/// This used to be its own constant, and it was turned off on 2026-08-12 for a +/// real reason: while it was on, a meal rider tapped **Arrived** and **Picked +/// up**, watched the card advance, and the hub console never changed. Every +/// status write on this line was recorded in a `Map` on the phone and reported +/// as a success. +/// +/// That failure mode has not gone away — it is simply what running without a +/// backend *means*, and it now applies to the whole app rather than to this +/// file alone. So the two switches became one: [kMockBackend] takes sign-in, +/// the day and every status write off the network together, and this follows +/// it. There is no state where the meal day is fictional but the statuses are +/// real, which is the confusing half of what used to be possible. +/// +/// Turn the app back on to the real backend with: +/// +/// flutter run --dart-define=MOCK_BACKEND=false +/// +/// The meal line then falls through to the three booking routes it actually +/// depends on, all of which exist and are proven by the parcel line: +/// +/// list `GET /miler/bookings` +/// arrived `POST /miler/bookings/:id/reached` +/// picked up `POST /miler/bookings/:id/pickup-complete` +/// Follows the one mock switch at runtime rather than at compile time, so a +/// test (or a bench session) that turns the canned backend off turns this off +/// with it. See [MockBackend.enabled]. +bool get kMealMockEnabled => MockBackend.enabled; + +/// Wall-clock helper so the mock day always looks like *today's* shift rather +/// than a fixed date that reads as stale the moment anybody opens it. +String _slot(int hour, int minute) { + final now = DateTime.now(); + final t = DateTime(now.year, now.month, now.day, hour, minute); + return t.toIso8601String(); +} + +/// A meal run held in memory: two kitchens, seven subscribers, one rider. +/// +/// Coordinates are real Coimbatore addresses in route order, because a route +/// that doubles back looks like a bug in the sequencing rather than what it is. +class MealRunMock { + MealRunMock._(); + + /// True when the app should serve this instead of calling the API. + static bool get active => + kMealMockEnabled && ServiceProfile.active.sourceIsKitchen; + + /// Status overrides the rider has caused this session, keyed by order id. + /// + /// The mock's own rows are `assigned`; everything after that is something he + /// did. Held separately rather than mutated into the rows so a reset is one + /// `clear()` and the day itself stays declarative. + static final Map _status = {}; + + /// Records what a status call *would* have sent. Always succeeds — there is + /// nothing to fail. + static bool setStatus(String orderId, String status) { + if (orderId.isEmpty) return true; + _status[orderId] = status; + debugPrint('[MEAL_MOCK] $orderId → $status'); + return true; + } + + /// Same, keyed by the numeric booking id the controllers carry. + static bool setStatusByPickupId(int pickupId, String status) { + if (pickupId <= 0) return true; + final row = _day.firstWhere( + (r) => r['pickupid'] == pickupId, + orElse: () => const {}, + ); + final id = (row['orderid'] ?? '').toString(); + return setStatus(id, status); + } + + /// Puts the day back to the top. Wired to nothing yet — it exists so a + /// demo can be run twice without reinstalling the app. + static void reset() => _status.clear(); + + /// The day as the cards read it, with whatever the rider has done applied. + /// + /// A fresh copy every call: the screens mutate stop maps in place (compliance + /// stamps, proof blocks), and handing out the master rows would let one run's + /// leftovers show up in the next. + static List> stops() => [ + for (final row in _day) + {...row, 'orderstatus': _status[row['orderid']] ?? row['orderstatus']}, + ]; + + // ── The day itself ────────────────────────────────────────────────────── + // + // Two kitchens is deliberate rather than decorative: a single-kitchen day + // hides every question the grouping exists to answer — which crate is this, + // which bags belong to it, what happens when the second load is still at a + // counter he has not reached. + // + // Five orders on the first counter and four on the second, because the whole + // pickup flow is a claim about a *set*: `5 orders · 5 bags`, five names on the + // confirmation, five deliveries afterwards. A day of ones would let every one + // of those read correctly while being wrong. + static final List> _day = [ + // ── Vidhya Kitchen · Gandhi Nagar, Peelamedu ── + _drop( + id: 1001, + step: 1, + name: 'Joe Mathew', + phone: '9840012001', + address: '12, SNS Colony, Peelamedu', + lat: 11.0271, + lng: 76.9962, + kitchen: _vidhya, + bag: 'DG-1001', + meals: 1, + ), + _drop( + id: 1002, + step: 2, + name: 'Arun Prakash', + phone: '9840012002', + address: '21, New Street, Peelamedu', + lat: 11.0248, + lng: 76.9941, + kitchen: _vidhya, + bag: 'DG-1002', + meals: 1, + ), + _drop( + id: 1003, + step: 3, + name: 'Priya Venkatesh', + phone: '9840012003', + address: '45, West Avenue, RS Puram', + lat: 11.0043, + lng: 76.9518, + kitchen: _vidhya, + bag: 'DG-1003', + meals: 1, + ), + _drop( + id: 1004, + step: 4, + name: 'Kumar Selvam', + phone: '9840012004', + address: 'Flat 3C, Lakshmi Towers, 100 Feet Road, Gandhipuram', + lat: 11.0168, + lng: 76.9558, + kitchen: _vidhya, + bag: 'DG-1004', + meals: 1, + ), + _drop( + id: 1005, + step: 5, + name: 'Ravi Shankar', + phone: '9840012005', + address: '8, Krishna Colony, 2nd Street, Singanallur', + lat: 10.9925, + lng: 77.0289, + kitchen: _vidhya, + bag: 'DG-1005', + meals: 1, + ), + + // ── Annapoorna Mess · Thadagam Road ── + _drop( + id: 2001, + step: 6, + name: 'Meena Krishnan', + phone: '9840012006', + address: '31, Sarojini Street, Gandhipuram', + lat: 11.0181, + lng: 76.9603, + kitchen: _annapoorna, + bag: 'AM-2001', + meals: 1, + ), + _drop( + id: 2002, + step: 7, + name: 'Sai Ganesh', + phone: '9840012007', + address: '14, Trichy Road, above the pharmacy, Ramanathapuram', + lat: 10.9871, + lng: 76.9931, + kitchen: _annapoorna, + bag: 'AM-2002', + meals: 1, + ), + _drop( + id: 2003, + step: 8, + name: 'Devi Lakshmi', + phone: '9840012008', + address: '22, Kamarajar Road, near the school gate, Uppilipalayam', + lat: 10.9903, + lng: 76.9977, + kitchen: _annapoorna, + bag: 'AM-2003', + meals: 1, + ), + _drop( + id: 2004, + step: 9, + name: 'Karthik Raja', + phone: '9840012009', + address: '5B, Thadagam Road, opposite the water tank', + lat: 11.0221, + lng: 76.9391, + kitchen: _annapoorna, + bag: 'AM-2004', + meals: 1, + ), + ]; + + /// A counter the rider collects from: what it is called, where it is, and the + /// id everything groups on. + static const _vidhya = ( + id: 'K1', + name: 'Vidhya Kitchen', + address: 'Gandhi Nagar, Peelamedu, Coimbatore - 641004', + lat: 11.0261, + lng: 76.9931, + locationId: 901, + ); + + static const _annapoorna = ( + id: 'K2', + name: 'Annapoorna Mess', + address: '19, Thadagam Road, RS Puram, Coimbatore - 641002', + lat: 11.0195, + lng: 76.9412, + locationId: 902, + ); + + /// One subscriber drop, in the legacy stop shape every card in the app reads. + /// + /// ── Both ends, in the right fields ── + /// + /// The `pickup*` fields are the **kitchen** and the `drop*` fields are the + /// **customer**, because that is what the app navigates on: before collection + /// `navigationTarget` reads `pickuplat/pickuplon`, and after it reads + /// `droplat/droplon` — see [MilkRun]. This fixture used to put the customer in + /// both, which sent a rider to a subscriber's flat to collect a lunch that was + /// still at a counter three kilometres away. + /// + /// `type: delivery` is stated rather than inferred: the app would reach the + /// same answer from the profile, but a fixture that relies on a fallback is + /// testing the fallback rather than the screen. + static Map _drop({ + required int id, + required int step, + required String name, + required String phone, + required String address, + required double lat, + required double lng, + required ({ + String id, + String name, + String address, + double lat, + double lng, + int locationId, + }) + kitchen, + required String bag, + required int meals, + }) => { + // `MOCK-` so `purgeDemoRecords()` recognises anything that leaks into a + // store. See the note at the top of this file. + 'orderid': 'MOCK-M-$id', + 'pickupid': id, + 'orderheaderid': id, + 'pickuplocationid': kitchen.locationId, + 'orderstatus': 'assigned', + 'step': step, + + // Who is being handed food. + 'pickupcustomer': name, + 'pickupcontactno': phone, + + // Where he collects: the counter. + 'pickupaddress': kitchen.address, + 'pickuplat': kitchen.lat, + 'pickuplon': kitchen.lng, + 'pickuplong': kitchen.lng, + + // Where it goes: the door. + 'dropaddress': '$address, Coimbatore, Tamil Nadu 641004', + 'droplat': lat, + 'droplon': lng, + + // The counter itself. The grouping, the headings, the manifest and the + // bag identity all key off these. + 'kitchenid': kitchen.id, + 'kitchenname': kitchen.name, + 'baglabel': bag, + + // A drop, and how many boxes are in it. One order is one bag — the count + // here is the *meal* count inside that bag, and nothing reads it as a bag + // count. See [BagManifest]. + 'type': 'delivery', + 'deliveryqty': meals, + 'quantity': meals, + + // Nothing is owed at any door — a subscriber paid the client by the month. + // Stated as zero rather than omitted so a payload reader cannot mistake a + // missing field for an unknown amount. + 'collectionamt': 0, + 'pickupamt': 0, + + // The lunch slot the whole run belongs to. + 'starttime': _slot(11, 30), + 'endtime': _slot(13, 30), + 'eta': '8', + 'kms': '2.4', + }; +} diff --git a/lib/data/miler_api.dart b/lib/data/miler_api.dart index f37a2c5..6a35822 100644 --- a/lib/data/miler_api.dart +++ b/lib/data/miler_api.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:miler/data/api_config.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/mutation_guard.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE MILER API — every `/miler/*` route, in one place. @@ -48,8 +50,87 @@ class MilerApi { /// The partition riders live in. static const int configId = 1001; + /// ── The operating company this build signs riders in for ── + /// + /// Sent as `tenantid` on both auth calls, so the backend can scope the login + /// to one company. Set at build time: + /// + /// flutter build apk --dart-define=TENANT_ID=7 + /// + /// Zero means "not specified" and the field is **omitted** rather than sent + /// as `0` — a zero looks like a real tenant to a server doing a lookup, and + /// an omitted field is the only way to say "you decide". + /// + /// ── What this value is and is not ── + /// + /// It is a *hint from the build*, not an authorisation claim. The rider's + /// real tenant is a column on his account row and the server is the only + /// thing that knows it; a client that asserts its own scope is a client that + /// can be modified to assert somebody else's. So the response wins wherever + /// it answers — see the tenant resolution in `auth_provider.dart`, which + /// falls back to this only while `verify-pin` returns no tenant at all. + /// + /// The day the backend returns `tenantid` on the profile, this keeps being + /// sent and stops being read, and nothing else has to change. + static const int tenantId = int.fromEnvironment('TENANT_ID'); + + /// True when this build was given a tenant to declare. + static bool get hasTenantId => tenantId > 0; + static const Duration _timeout = Duration(seconds: 20); + /// How long to wait before asking again for the result of a write the server + /// says it is already running, and how many times. Kept small: the rider is + /// standing at a door with his thumb on the screen, and beyond a second or + /// so a spinner stops reading as *working* and starts reading as *stuck*. + static const Duration _inFlightBackoff = Duration(milliseconds: 600); + static const int _inFlightRetries = 2; + + /// ── The one seam in the transport ── + /// + /// Every `/miler/*` route goes through [_send], and [_send] went straight to + /// the `http` package's top-level functions — which are not injectable, so the + /// only way to assert what this client actually *puts on the wire* was to let + /// it reach the network. + /// + /// That matters more here than it usually would, because several fields in + /// this contract are quietly load-bearing: `device_token` is snake_case where + /// everything around it is not, telemetry numbers are **strings**, telemetry + /// bodies must carry no `userid`, availability sends `Break` and not + /// `On_Break`, and `vehicle-required` takes query parameters rather than a + /// body. Every one of those is a silent failure if it drifts, and none of them + /// was covered. + /// + /// A swappable client makes the request itself the thing under test. Nothing + /// in the app assigns this; only tests do. + @visibleForTesting + static http.Client client = http.Client(); + + /// Wraps a non-idempotent mutation so a double tap cannot send it twice. + /// + /// A dropped duplicate comes back as a **failed result carrying the reason** + /// rather than as null, so a caller that does not know about the guard still + /// takes its error path instead of reading a null as success. See + /// [MutationGuard] for why a second press is dropped rather than queued. + static Future _guarded( + String key, + Future Function() send, + ) async => + await MutationGuard.run(key, send) ?? + const ApiResult( + ok: false, + status: 0, + message: 'That is already going through — give it a moment.', + ); + + /// Called once when an authenticated call is refused by the server. + /// + /// The transport does the irreversible half — dropping the dead token — and + /// this hands the rest to whoever owns navigation, so this file never has to + /// know what a screen is. Set by the app shell; null in tests unless one is + /// asserting on it. + static void Function()? onUnauthorized; + /// Telemetry wants strings for values that are numbers everywhere else. static String _str(dynamic v) => v == null ? '' : v.toString(); @@ -76,34 +157,71 @@ class MilerApi { Object? body, Map? query, bool auth = true, + + /// Sent as `Idempotency-Key`. A retry with the same key replays the first + /// response instead of writing twice — see [_idempotencyKey]. + String? idempotencyKey, + + /// How many times an `IDEMPOTENCY_IN_PROGRESS` answer has already been + /// waited out on this call. Internal — see [_inFlightRetries]. + int inFlightAttempt = 0, }) async { + // ── Nothing leaves the device while the mock is on ── + // + // Intercepted here rather than per call site, because this is the one door + // every `/miler/*` route goes through — and a mock that covers 38 routes by + // covering one function cannot drift out of step with the ones it missed. + // The canned body is handed to exactly the same envelope handling as a real + // 200, so the app cannot tell the difference and neither can this file. + if (MockBackend.enabled) { + final canned = MockBackend.respond( + method, + path, + body: body, + query: query, + ); + if (canned != null) { + return ApiResult( + ok: true, + status: 200, + data: canned['data'] ?? canned, + raw: canned, + message: _str(canned['message']), + ); + } + } + final uri = Uri.parse( ApiConfig.url(path), ).replace(queryParameters: (query == null || query.isEmpty) ? null : query); - final headers = auth - ? await ApiConfig.authHeaders() - : const { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }; + final headers = { + ...(auth + ? await ApiConfig.authHeaders() + : const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }), + if (idempotencyKey != null && idempotencyKey.isNotEmpty) + 'Idempotency-Key': idempotencyKey, + }; final encoded = body == null ? null : json.encode(body); try { late http.Response res; switch (method) { case 'GET': - res = await http.get(uri, headers: headers).timeout(_timeout); + res = await client.get(uri, headers: headers).timeout(_timeout); case 'POST': - res = await http + res = await client .post(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PUT': - res = await http + res = await client .put(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PATCH': - res = await http + res = await client .patch(uri, headers: headers, body: encoded) .timeout(_timeout); default: @@ -131,13 +249,60 @@ class MilerApi { data: decoded is Map ? (decoded['data'] ?? decoded) : decoded, raw: decoded, message: decoded is Map ? _str(decoded['message']) : '', + code: decoded is Map ? _str(decoded['code']) : '', ); + // ── The duplicate that is not a failure ── + // + // `IDEMPOTENCY_IN_PROGRESS` means the server is *at this moment* running + // the very request being retried — the first attempt's response was + // lost, not its effect. Surfacing it would tell a rider his delivery + // failed while it is in the act of succeeding, and a fresh press would + // meet the same answer. + // + // It is also not a reason to declare success: the write in flight can + // still fail. So the call waits and asks again, and the idempotent + // replay hands back the first attempt's real outcome. Two short waits, + // then whatever the server says is passed through as-is. + if (!result.ok && + result.isInFlight && + inFlightAttempt < _inFlightRetries) { + debugPrint('[API] $method $path -> in flight, waiting for the answer'); + await Future.delayed(_inFlightBackoff * (inFlightAttempt + 1)); + return _send( + method, + path, + body: body, + query: query, + auth: auth, + idempotencyKey: idempotencyKey, + inFlightAttempt: inFlightAttempt + 1, + ); + } + if (!result.ok) { debugPrint( - '[API] $method $path -> ${res.statusCode} ${result.message} ' + '[API] $method $path -> ${res.statusCode} ' + '${result.code.isEmpty ? '' : '[${result.code}] '}${result.message} ' '${res.body.length > 300 ? '${res.body.substring(0, 300)}…' : res.body}', ); + // ── One place decides that a session is over ── + // + // A 401 used to surface as an ordinary failed call, so every screen + // handled it — or, mostly, did not: the rider stayed visually signed in + // with a dead token, every list refreshed to empty, and nothing said + // why. Handled here because this is the one door all 38 routes pass + // through, and because the decision is the same wherever it happens. + // + // The token is dropped so nothing retries with it, and [onUnauthorized] + // lets the shell take the rider back to sign-in. Auth's own two routes + // are exempt: a wrong PIN is a 401 about a credential, not an expired + // session, and clearing state there would be a logout in response to a + // typo. + if (res.statusCode == 401 && auth) { + await ApiConfig.clearToken(); + onUnauthorized?.call(); + } } return result; } catch (e) { @@ -156,7 +321,11 @@ class MilerApi { 'POST', '/miler/login', auth: false, - body: {'phone': phone, 'configid': configId}, + body: { + 'phone': phone, + 'configid': configId, + if (hasTenantId) 'tenantid': tenantId, + }, ); /// Step 2. On success the token is stored and the caller gets `user`. @@ -176,6 +345,7 @@ class MilerApi { 'phone': phone, 'pin': pin, 'configid': configId, + if (hasTenantId) 'tenantid': tenantId, if (deviceToken != null && deviceToken.isNotEmpty) 'device_token': deviceToken, }, @@ -198,12 +368,16 @@ class MilerApi { String? profilePhotoUrl, String? defaultVehicleType, String? phone, - }) => _send('PUT', '/miler/profile', body: { - if (displayName != null) 'displayname': displayName, - if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl, - if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType, - if (phone != null) 'phone': phone, - }); + }) => _send( + 'PUT', + '/miler/profile', + body: { + if (displayName != null) 'displayname': displayName, + if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl, + if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType, + if (phone != null) 'phone': phone, + }, + ); /// snake_case, unlike the rest of the contract. static Future setDeviceToken(String token) => @@ -221,22 +395,29 @@ class MilerApi { String? pincode, double? speed, double? heading, - }) => _send('PUT', '/miler/location', body: { - 'latitude': latitude, - 'longitude': longitude, - if (pincode != null && pincode.isNotEmpty) 'pincode': pincode, - if (speed != null) 'speed': speed, - if (heading != null) 'heading': heading, - }); + }) => _send( + 'PUT', + '/miler/location', + body: { + 'latitude': latitude, + 'longitude': longitude, + if (pincode != null && pincode.isNotEmpty) 'pincode': pincode, + if (speed != null) 'speed': speed, + if (heading != null) 'heading': heading, + }, + ); /// One of [availabilityStatuses]. Sent under both keys the backend has /// accepted at different times, so neither a doc nor a handler change can /// silently drop it. - static Future setAvailability(String status) => - _send('PUT', '/miler/availability', body: { - 'status': status, - 'availabilitystatus': status, - }); + static Future setAvailability(String status) => _guarded( + 'availability', + () => _send( + 'PUT', + '/miler/availability', + body: {'status': status, 'availabilitystatus': status}, + ), + ); /// Note `Break`, not `On_Break` — the obvious guess is the wrong one. static const List availabilityStatuses = [ @@ -259,23 +440,35 @@ class MilerApi { // reconcile (re-read `dutyCurrent`), not as failures to retry. // ═══════════════════════════════════════════════════════════════════════ - static Future startDuty({double? lat, double? lon}) => - _send('POST', '/miler/duty/start', body: { - 'lat': lat ?? 0, - 'lon': lon ?? 0, - }); + // Guarded on one key for both directions: starting duty twice is a + // server-side error, and so is ending it twice — and a rider who taps the + // switch again because the first tap "did nothing" is the commonest way to + // produce either. + static Future startDuty({double? lat, double? lon}) => _guarded( + 'duty', + () => _send( + 'POST', + '/miler/duty/start', + body: {'lat': lat ?? 0, 'lon': lon ?? 0}, + ), + ); - static Future endDuty() => _send('PUT', '/miler/duty/end'); + static Future endDuty() => + _guarded('duty', () => _send('PUT', '/miler/duty/end')); - static Future dutyCurrent() => - _send('GET', '/miler/duty/current'); + static Future dutyCurrent() => _send('GET', '/miler/duty/current'); - static Future startBreak(String breakType) => - _send('POST', '/miler/breaks/start', body: { - 'breaktype': breakType.isEmpty ? 'Personal' : breakType, - }); + static Future startBreak(String breakType) => _guarded( + 'break', + () => _send( + 'POST', + '/miler/breaks/start', + body: {'breaktype': breakType.isEmpty ? 'Personal' : breakType}, + ), + ); - static Future endBreak() => _send('PUT', '/miler/breaks/end'); + static Future endBreak() => + _guarded('break', () => _send('PUT', '/miler/breaks/end')); // ═══════════════════════════════════════════════════════════════════════ // ASSIGNMENTS @@ -289,8 +482,10 @@ class MilerApi { /// Keyed on `bookingassignmentid`, **not** `bookingid` — they come from /// different sequences. Resolve through `AssignmentLookup` first or the /// backend answers 404 while the rider is shown success. - static Future acceptAssignment(Object assignmentId) => - _send('POST', '/miler/assignments/$assignmentId/accept', body: {}); + static Future acceptAssignment(Object assignmentId) => _guarded( + 'accept:$assignmentId', + () => _send('POST', '/miler/assignments/$assignmentId/accept', body: {}), + ); /// The deployed handler reads `reason` from the query string; the contract /// doc says the body. Sent both ways — this route has never had a real @@ -298,11 +493,14 @@ class MilerApi { static Future rejectAssignment( Object assignmentId, { required String reason, - }) => _send( - 'POST', - '/miler/assignments/$assignmentId/reject', - query: {'reason': reason}, - body: {'reason': reason}, + }) => _guarded( + 'reject:$assignmentId', + () => _send( + 'POST', + '/miler/assignments/$assignmentId/reject', + query: {'reason': reason}, + body: {'reason': reason}, + ), ); // ═══════════════════════════════════════════════════════════════════════ @@ -314,10 +512,62 @@ class MilerApi { Object bookingId, { double? lat, double? lon, - }) => _send('POST', '/miler/bookings/$bookingId/reached', body: { - if (lat != null) 'latitude': lat, - if (lon != null) 'longitude': lon, - }); + }) => _guarded( + 'reached:$bookingId', + () => _send( + 'POST', + '/miler/bookings/$bookingId/reached', + body: { + if (lat != null) 'latitude': lat, + if (lon != null) 'longitude': lon, + }, + ), + ); + + /// Step 1b — **the FROM and TO the rider confirms at the door.** + /// + /// A logistics booking often reaches the rider half-addressed: raised from a + /// map pin, destination described as a phone number and a landmark. He is the + /// first person who can stand in front of the sender and ask, and the answers + /// decide the consignment's routing and its pricing zone. + /// + /// Every field is optional and only non-empty ones are applied server-side, + /// so this is a correction and never a wipe. Must land **before** + /// [pickupComplete], which builds the consignment from these values; the + /// handler refuses it once the booking has been converted. + static Future updateBookingAddresses( + Object bookingId, { + String? pickupAddress, + String? pickupPincode, + double? pickupLat, + double? pickupLon, + String? deliveryAddress, + String? deliveryPincode, + double? deliveryLat, + double? deliveryLon, + String? deliveryCity, + }) => _send( + 'PATCH', + '/miler/bookings/$bookingId/addresses', + body: { + if (pickupAddress != null && pickupAddress.isNotEmpty) + 'pickupaddress': pickupAddress, + if (pickupPincode != null && pickupPincode.isNotEmpty) + 'pickuppincode': pickupPincode, + if (pickupLat != null && pickupLat != 0) 'pickuplatitude': pickupLat, + if (pickupLon != null && pickupLon != 0) 'pickuplongitude': pickupLon, + if (deliveryAddress != null && deliveryAddress.isNotEmpty) + 'deliveryaddress': deliveryAddress, + if (deliveryPincode != null && deliveryPincode.isNotEmpty) + 'deliverypincode': deliveryPincode, + if (deliveryLat != null && deliveryLat != 0) + 'deliverylatitude': deliveryLat, + if (deliveryLon != null && deliveryLon != 0) + 'deliverylongitude': deliveryLon, + if (deliveryCity != null && deliveryCity.isNotEmpty) + 'deliverycity': deliveryCity, + }, + ); /// Step 2. The dimensions here are what `pickup-complete` recomputes /// chargeable weight from, so this is the one moment the parcel's measured @@ -325,9 +575,16 @@ class MilerApi { static Future submitParcels( Object bookingId, List parcels, - ) => _send('POST', '/miler/bookings/$bookingId/parcel', body: { - 'parcels': [for (final p in parcels) p.toJson()], - }); + ) => _guarded( + 'parcel:$bookingId', + () => _send( + 'POST', + '/miler/bookings/$bookingId/parcel', + body: { + 'parcels': [for (final p in parcels) p.toJson()], + }, + ), + ); /// Step 3. `amount` must be > 0; `paymentmode` is one of [paymentModes]. static Future submitPayment( @@ -335,11 +592,19 @@ class MilerApi { required double amount, required String paymentMode, String? transactionRef, - }) => _send('POST', '/miler/bookings/$bookingId/payment', body: { - 'amount': amount, - 'paymentmode': paymentMode, - 'transactionref': transactionRef ?? '', - }); + }) => _guarded( + 'payment:$bookingId', + () => _send( + 'POST', + '/miler/bookings/$bookingId/payment', + idempotencyKey: _idempotencyKey('payment', bookingId), + body: { + 'amount': amount, + 'paymentmode': paymentMode, + 'transactionref': transactionRef ?? '', + }, + ), + ); static const List paymentModes = ['Cash', 'UPI', 'Card', 'Wallet']; @@ -348,14 +613,84 @@ class MilerApi { /// step 2, and decides routing — a shared 3-digit pincode prefix means /// hyperlocal and the consignment goes straight to `Out_for_Delivery` in this /// rider's hands, otherwise it routes via the hub. + /// A key that identifies **one rider action**, so a retry after a dropped + /// acknowledgement replays the first result instead of writing twice. + /// + /// Derived, not random: the same action retried must produce the *same* key + /// or the replay never matches. `verb:resource:day` is stable across a + /// retry, an app restart and a lost response, while still letting a genuine + /// second attempt tomorrow through. + /// + /// This complements [MutationGuard] rather than replacing it — the guard + /// stops a double *press* on this device, the key stops a double *write* on + /// the server after the network lost the first answer. + static String _idempotencyKey(String verb, Object resource) { + final now = DateTime.now(); + final day = + '${now.year}${now.month.toString().padLeft(2, '0')}' + '${now.day.toString().padLeft(2, '0')}'; + return '$verb:$resource:$day'; + } + static Future pickupComplete( Object bookingId, { double? lat, double? lon, - }) => _send('POST', '/miler/bookings/$bookingId/pickup-complete', body: { - if (lat != null) 'latitude': lat, - if (lon != null) 'longitude': lon, - }); + }) => _guarded( + 'pickup-complete:$bookingId', + () => _send( + 'POST', + '/miler/bookings/$bookingId/pickup-complete', + idempotencyKey: _idempotencyKey('pickup-complete', bookingId), + body: { + if (lat != null) 'latitude': lat, + if (lon != null) 'longitude': lon, + }, + ), + ); + + // ═══════════════════════════════════════════════════════════════════════ + // THE RELEASE — collected → out for delivery + // ═══════════════════════════════════════════════════════════════════════ + + /// Moves a `Collected_By_Miler` consignment to `Out_for_Delivery`. + /// + /// ── This is the step the app spent months without ── + /// + /// `pickup-complete` used to push hyperlocal work straight to + /// `Out_for_Delivery`, so the hub saw a rider "actively delivering" food + /// still on the kitchen counter, and there was no server state for + /// *collected, holding*. The app modelled the difference locally, which the + /// hub could not see and a reinstall erased. + /// + /// Now `pickup-complete` lands on `Collected_By_Miler` and this call — the + /// rider pressing **Start round** — makes the release. Two consequences + /// worth knowing: + /// + /// • **The receiver OTP is issued here**, not at pickup, so a code is not + /// in the customer's hands during the holding period. + /// • **The rider's availability flips** `Picked_Up` → `On_Delivery`. + /// + /// Replaces the old `POST /miler/deliveries/start`, which never existed. + static Future startDelivery(Object consignmentId) => _guarded( + 'start-delivery:$consignmentId', + () => _send( + 'POST', + '/miler/consignments/$consignmentId/start-delivery', + idempotencyKey: _idempotencyKey('start-delivery', consignmentId), + body: const {}, + ), + ); + + /// The consignment's current state and what may be done to it. + /// + /// Returns `status` plus the backend's own derived flags — `collected`, + /// `out_for_delivery`, `delivered`, `can_start_delivery`, `can_deliver`, + /// `can_skip` — and the COD figures. Replaces walking + /// `GET /miler/consignments/logs/:id` and reading the last event, which was + /// a history replay standing in for a state read. + static Future consignment(Object consignmentId) => + _send('GET', '/miler/consignments/$consignmentId'); /// Query strings, not a body — the handler reads `c.Query`. static Future vehicleRequired( @@ -372,19 +707,96 @@ class MilerApi { static Future cancelBooking( Object bookingId, { required String reason, - }) => _send('POST', '/miler/bookings/$bookingId/cancel', body: { - 'reason': reason, - }); + }) => _guarded( + 'cancel:$bookingId', + () => _send( + 'POST', + '/miler/bookings/$bookingId/cancel', + body: {'reason': reason}, + ), + ); + + // ═══════════════════════════════════════════════════════════════════════ + // PRICING — what this shipment costs, from the real numbers + // ═══════════════════════════════════════════════════════════════════════ + + /// The fare band for a shipment, quoted at the door. + /// + /// `POST /api/v1/pricing/check` — note it is **not** under `/miler`, and it + /// takes no auth. The zone is derived server-side from the two pincodes when + /// [zone] is omitted, which is the path the rider app uses: he has just + /// captured both addresses and has no business deciding what a zone is. + /// + /// Returns `{found, zone, service_type, weight, currency, results: [{category, + /// category_label, min_price, max_price}]}` — a *band* per category, not a + /// single number, because that is what the pricing table holds. When `found` + /// is false the combination has no rule configured and the rider must be told + /// that rather than shown a zero. See [ShipmentQuote]. + static Future checkPrice({ + required double weight, + required String serviceType, + String? zone, + String? pickupPincode, + String? deliveryPincode, + String? category, + }) => _send( + 'POST', + '/pricing/check', + body: { + 'weight': weight, + 'service_type': serviceType, + if (zone != null && zone.isNotEmpty) 'zone': zone, + if (pickupPincode != null && pickupPincode.isNotEmpty) + 'pickup_pincode': pickupPincode, + if (deliveryPincode != null && deliveryPincode.isNotEmpty) + 'delivery_pincode': deliveryPincode, + if (category != null && category.isNotEmpty) 'category': category, + }, + ); + + /// The service tiers the pricing table is keyed on. `Express` — not + /// `Fast`/`Superfast`, which is what the *booking* service options use. + static const List serviceTypes = ['Normal', 'Express']; + + /// Parcel categories the pricing table recognises. + static const List parcelCategories = [ + 'General', + 'Documents', + 'Electronics', + 'Clothing', + 'Fragile', + 'Medical', + 'Automotive', + 'Food', + ]; // ═══════════════════════════════════════════════════════════════════════ // DELIVERY // ═══════════════════════════════════════════════════════════════════════ + // ── `POST /miler/deliveries/start` is not on this API ── + // + // It was declared here and called after every pickup. It is absent from the + // contract's 38 routes and from the backend's route table, so it 404'd every + // time — and the app was built to believe a consignment only became + // deliverable once it had "worked". + // + // It never needed to. `pickup-complete` decides routing itself: matching + // 3-digit pickup and delivery pincode prefixes are hyperlocal and the + // consignment goes straight to `Out_for_Delivery` in the rider's hands. There + // is no rider-facing release step to make, so there is no endpoint to call. + // + // Do not add it back without a contract change. What the rider presses is a + // *local* record of having set off — see `OrderEvent.outForDelivery`. + /// The consignment must be `Out_for_Delivery` or this is a 400. /// - /// `otp` is only required when the tenant has `requiredeliveryotp` on — it is - /// off by default. When it is on the code is checked server-side, so a - /// non-empty string is not enough. + /// `otp` is **optional**. There is no delivery-OTP column on consignments and + /// nothing generates one, so the handler cannot check a code against + /// anything; it records whether one was presented. Requiring it used to force + /// any caller without an OTP — a milk-run drop, where a subscriber's lunch is + /// handed over with no code — to invent a value, which is worse than + /// recording that there was none. Do not start sending a placeholder. /// /// `lat`/`lon` must be the actual delivery point: the server computes /// `riderkms` from the pickup coords by haversine and writes it onto the @@ -398,14 +810,22 @@ class MilerApi { String? receiverSignatureUrl, double? lat, double? lon, - }) => _send('POST', '/miler/consignments/$consignmentId/deliver', body: { - 'deliveredtoname': deliveredToName, - if (otp != null && otp.isNotEmpty) 'otp': otp, - 'photourl': photoUrl ?? '', - 'receiversignatureurl': receiverSignatureUrl ?? '', - if (lat != null) 'lat': lat, - if (lon != null) 'lon': lon, - }); + }) => _guarded( + 'deliver:$consignmentId', + () => _send( + 'POST', + '/miler/consignments/$consignmentId/deliver', + idempotencyKey: _idempotencyKey('deliver', consignmentId), + body: { + 'deliveredtoname': deliveredToName, + if (otp != null && otp.isNotEmpty) 'otp': otp, + 'photourl': photoUrl ?? '', + 'receiversignatureurl': receiverSignatureUrl ?? '', + if (lat != null) 'lat': lat, + if (lon != null) 'lon': lon, + }, + ), + ); /// Bumps `attemptcount` rather than failing the consignment — a skip is a /// return visit, not an outcome. @@ -414,28 +834,43 @@ class MilerApi { required String reason, double? lat, double? lon, - }) => _send('POST', '/miler/consignments/$consignmentId/skip', body: { - 'reason': reason, - if (lat != null) 'lat': lat, - if (lon != null) 'lon': lon, - }); + }) => _guarded( + 'skip:$consignmentId', + () => _send( + 'POST', + '/miler/consignments/$consignmentId/skip', + idempotencyKey: _idempotencyKey('skip', consignmentId), + body: { + 'reason': reason, + if (lat != null) 'lat': lat, + if (lon != null) 'lon': lon, + }, + ), + ); // ═══════════════════════════════════════════════════════════════════════ // BOOKINGS & EARNINGS // ═══════════════════════════════════════════════════════════════════════ - static Future bookings({String? status, String? date}) => - _send('GET', '/miler/bookings', query: { - if (status != null && status.isNotEmpty) 'status': status, - if (date != null && date.isNotEmpty) 'date': date, - }); + static Future bookings({String? status, String? date}) => _send( + 'GET', + '/miler/bookings', + query: { + if (status != null && status.isNotEmpty) 'status': status, + if (date != null && date.isNotEmpty) 'date': date, + }, + ); /// `bonuspoints` on this response is always zero — nothing writes it yet. static Future earnings({String period = 'daily', String? date}) => - _send('GET', '/miler/earnings', query: { - 'period': period, - if (date != null && date.isNotEmpty) 'date': date, - }); + _send( + 'GET', + '/miler/earnings', + query: { + 'period': period, + if (date != null && date.isNotEmpty) 'date': date, + }, + ); // ═══════════════════════════════════════════════════════════════════════ // TELEMETRY — Redis-backed, high frequency @@ -464,21 +899,25 @@ class MilerApi { String? locationService, bool isBackground = false, DateTime? at, - }) => _send('POST', '/miler/logs', body: { - 'logdate': logStamp(at), - 'latitude': _str(latitude), - 'longitude': _str(longitude), - if (speed != null) 'speed': _str(speed), - if (heading != null) 'heading': _str(heading), - if (accuracy != null) 'accuracy': _str(accuracy), - if (status != null && status.isNotEmpty) 'status': status, - if (orderId != null) 'orderid': _str(orderId), - if (battery != null) 'battery': _str(battery), - 'is_charging': isCharging, - if (connection != null) 'connection': connection, - if (locationService != null) 'location_service': locationService, - 'is_background': isBackground, - }); + }) => _send( + 'POST', + '/miler/logs', + body: { + 'logdate': logStamp(at), + 'latitude': _str(latitude), + 'longitude': _str(longitude), + if (speed != null) 'speed': _str(speed), + if (heading != null) 'heading': _str(heading), + if (accuracy != null) 'accuracy': _str(accuracy), + if (status != null && status.isNotEmpty) 'status': status, + if (orderId != null) 'orderid': _str(orderId), + if (battery != null) 'battery': _str(battery), + 'is_charging': isCharging, + if (connection != null) 'connection': connection, + if (locationService != null) 'location_service': locationService, + 'is_background': isBackground, + }, + ); static Future getLogs() => _send('GET', '/miler/logs'); @@ -521,10 +960,11 @@ class MilerApi { static Future createSupportTicket({ required String subject, required String description, - }) => _send('POST', '/miler/support', body: { - 'subject': subject, - 'description': description, - }); + }) => _send( + 'POST', + '/miler/support', + body: {'subject': subject, 'description': description}, + ); static Future supportTickets() => _send('GET', '/miler/support'); @@ -540,6 +980,15 @@ class MilerApi { 'a real notifications table with read state', 'anything that writes bonuspoints', 'cancelled / total counts on GET /miler/earnings', + // The quoted fare has nowhere to be recorded: `POST /bookings/:id/payment` + // stores what was COLLECTED, and `bookingserviceoptions.estimatedprice` is + // written when the customer books and is not rider-writable. So a rider who + // re-prices a shipment at the door leaves the original estimate on the + // booking and only the collected amount reflects the new figure. + 'a rider-writable quoted price on the booking (only the collected amount lands)', + // A delivery-OTP column on consignments. Until one exists, `deliver` + // records whether a code was presented, not whether it was correct. + 'a real delivery OTP on consignments (deliver cannot verify a code today)', ]; } @@ -572,6 +1021,113 @@ class ParcelEntry { }; } +/// What the pricing table says this shipment costs. +/// +/// ── Why a band and not a number ── +/// +/// `POST /pricing/check` answers with a min/max per category, because that is +/// what the table holds — a rule is a weight slab in a zone, and it prices a +/// range. The rider has to quote one figure to a customer, so [payable] takes +/// the **minimum**: it is the number the customer was shown when the booking +/// was raised, and quoting the top of a band at the door is how a rider ends up +/// arguing about money on a doorstep. +/// +/// [found] false means no rule covers this weight/zone/category combination. +/// That is not a zero — it is "this app cannot price this", and the rider must +/// be told so rather than shown a free shipment. Nothing here invents a fare. +@immutable +class ShipmentQuote { + /// True when the table had a rule for this combination. + final bool found; + + /// `Local` / `Regional` / `National`, as resolved from the two pincodes. + final String zone; + final String serviceType; + final double weight; + final String currency; + final String category; + final double minPrice; + final double maxPrice; + + /// The server's own words when it could not price this. + final String message; + + const ShipmentQuote({ + required this.found, + this.zone = '', + this.serviceType = '', + this.weight = 0, + this.currency = 'INR', + this.category = '', + this.minPrice = 0, + this.maxPrice = 0, + this.message = '', + }); + + /// The figure quoted to the customer and taken at the door. + double get payable => minPrice; + + /// True when the band is wide enough that the two ends are different money. + bool get isRange => maxPrice > minPrice; + + static double _num(dynamic v) { + if (v is num) return v.toDouble(); + return double.tryParse(v?.toString() ?? '') ?? 0; + } + + /// Reads the `data` block of a `/pricing/check` response. + /// + /// [preferredCategory] picks a row out of `results` when the rider named a + /// category; without one the cheapest row wins, for the same reason [payable] + /// takes the minimum. + factory ShipmentQuote.fromData( + Map data, { + String preferredCategory = '', + }) { + final found = data['found'] == true; + final results = (data['results'] is List) + ? (data['results'] as List).whereType().toList() + : const []; + + if (!found || results.isEmpty) { + return ShipmentQuote( + found: false, + zone: (data['zone'] ?? '').toString(), + serviceType: (data['service_type'] ?? '').toString(), + weight: _num(data['weight']), + message: (data['message'] ?? 'no price configured for this shipment') + .toString(), + ); + } + + Map? chosen; + if (preferredCategory.isNotEmpty) { + for (final r in results) { + if ((r['category'] ?? '').toString().toLowerCase() == + preferredCategory.toLowerCase()) { + chosen = r; + break; + } + } + } + chosen ??= results.reduce( + (a, b) => _num(a['min_price']) <= _num(b['min_price']) ? a : b, + ); + + return ShipmentQuote( + found: true, + zone: (data['zone'] ?? '').toString(), + serviceType: (data['service_type'] ?? '').toString(), + weight: _num(data['weight']), + currency: (data['currency'] ?? 'INR').toString(), + category: (chosen['category_label'] ?? chosen['category'] ?? '') + .toString(), + minPrice: _num(chosen['min_price']), + maxPrice: _num(chosen['max_price']), + ); + } +} + /// One `ConsignmentLog`. Numeric fields are strings, as with [MilerApi.postLog]. class ConsignmentLogEntry { final Object consignmentId; @@ -622,6 +1178,16 @@ class ApiResult { final bool ok; final int status; + /// The backend's stable machine-readable failure code — `INVALID_STATE`, + /// `OTP_REQUIRED`, `CONSIGNMENT_NOT_ASSIGNED`, `IDEMPOTENCY_IN_PROGRESS`… + /// + /// Added to the `/miler/*` 4xx envelope on 21 Aug 2026. **Branch on this, + /// never on [message]** — string-matching a human sentence is how the app + /// came to tell riders "the hub hasn't released this" for a consignment that + /// had already been delivered. Empty on success and on any route that does + /// not carry one yet. + final String code; + /// The envelope's `data`, or the whole body when there is no `data` key. final dynamic data; @@ -637,11 +1203,20 @@ class ApiResult { this.data, this.raw, this.message = '', + this.code = '', }); + /// True when the backend refused because the entity is in the wrong state. + bool get isInvalidState => code == 'INVALID_STATE'; + + /// True when an identical request is still running server-side — retry + /// shortly rather than treating it as a failure. + bool get isInFlight => code == 'IDEMPOTENCY_IN_PROGRESS'; + /// [data] as a map, or an empty one. - Map get map => - data is Map ? Map.from(data as Map) : {}; + Map get map => data is Map + ? Map.from(data as Map) + : {}; /// [data] as a list, tolerating the envelope shapes seen in the wild: /// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`. diff --git a/lib/data/milk_run.dart b/lib/data/milk_run.dart new file mode 100644 index 0000000..4292f9f --- /dev/null +++ b/lib/data/milk_run.dart @@ -0,0 +1,441 @@ +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE MILK RUN — accept per order, collect per kitchen, deliver per order. +/// +/// ``` +/// HOME DELIVERIES ACTIVITY +/// ──── ────────── ──────── +/// 6 orders ─ accept ─┬─ Vidhya ×3 ─ arrived ─ picked ─▶ deliver ─▶ done +/// ├─ Priyanka×2 ─ arrived ─ picked ─▶ deliver ─▶ done +/// └─ ABC ×1 ─ arrived ─ picked ─▶ deliver ─▶ done +/// ``` +/// +/// ── The three units, and why they differ ── +/// +/// **Acceptance is per order.** The rider takes on the morning's work in one +/// press, whichever counters it comes from. +/// +/// **Pickup is per kitchen.** Three orders from Vidhya Kitchen are one ride, +/// one counter and one handover. He arrives once and is then given bags one at +/// a time, so arrival is grouped and collection is per bag — and a selection +/// must never span two kitchens, or he posts "arrived" for a shop he is +/// nowhere near. See [sameSourceAs]. +/// +/// **Delivery is per order.** Each bag goes to its own door. +/// +/// Accepted orders therefore stay individually visible on Home; the kitchen is +/// what bounds a pickup operation, not what replaces the cards. +/// +/// ── Collected work goes out immediately ── +/// +/// There is no "start delivery" gate. A kitchen's orders become live +/// deliveries the moment they are in the rider's hands, so he can drop the +/// first three while a second kitchen is still cooking. Holding them until +/// every counter was done made the first customers wait for food already on +/// the bike. +/// +/// ── How client stages map to the real backend ── +/// +/// The UI has more stages than the API has statuses, which is fine as long as +/// every one either writes something real or is honestly local. None invents a +/// server state: +/// +/// | Rider does | UI stage | Server call | +/// |-------------------|--------------------|--------------------------------------| +/// | Accepts work | `accepted` | `POST /assignments/:aid/accept` | +/// | Reaches a kitchen | `arrived` | `POST /bookings/:id/reached` | +/// | Takes a bag | `picked` | `POST /bookings/:id/pickup-complete` | +/// | (rides on) | `outForDelivery` | `POST /deliveries/start` ¹ | +/// | Reaches a door | `deliveryArrived` | *(local — no route exists)* ² | +/// | Hands it over | `delivered` | `POST /consignments/:cid/deliver` | +/// +/// ¹ **not deployed.** Fired best-effort as part of pickup; until it ships the +/// consignments stay `Inwarded_at_Hub` and `deliver` refuses them. +/// ² **no endpoint exists.** See [deliveryArrivalIsLocalOnly]. +/// +/// Everything here is a pure function over the stop maps the rest of the app +/// already passes around, so it can be tested without a widget tree, a GetX +/// container or a network. Same reasoning as `AssignmentLookup.buildIndex`. +/// ───────────────────────────────────────────────────────────────────────── +class MilkRun { + MilkRun._(); + + /// **BACKEND DEPENDENCY.** There is no route that records a rider arriving at + /// a *delivery* address — `/bookings/:id/reached` keys on a booking in its + /// pickup phase, and the consignment routes have no arrival event. So the + /// `deliveryArrived` rung is held on the device and the hub sees the round + /// jump from out-for-delivery straight to delivered. + /// + /// Nothing is faked to cover this: no call is made and no success is + /// invented. The rung exists because the *rider* needs the distinction — it + /// is what turns his Deliver button on at the right door — and the flag is + /// here so the gap is greppable rather than folklore. + static const bool deliveryArrivalIsLocalOnly = true; + + /// Order id of a stop, however the payload spells it. + static String idOf(Map stop) => + (stop['orderid'] ?? stop['orderId'] ?? '').toString(); + + /// The consignment this stop became once it was collected, or '' before that. + /// + /// The delivery route keys on this and not on the booking id — they come from + /// different sequences, the same trap [AssignmentLookup] exists for on the + /// accept side. A stop without one cannot be delivered yet. + static String consignmentIdOf(Map stop) => + (stop['consignmentid'] ?? stop['consignmentId'] ?? '').toString().trim(); + + // ── Which counter a stop is collected from ── + // + // Pickup is grouped by kitchen even though acceptance and delivery are per + // order. Three orders from Vidhya Kitchen are one ride, one counter and one + // handover; the rider works them together and must not be able to sweep an + // order from a different kitchen into that operation. + // + // The key is the **stable id** where the payload carries one, because two + // kitchens can share a display name across areas and the id is what the hub + // controls. A name-only payload still groups rather than collapsing into one + // nameless pile, and a stop with neither gets its own bucket rather than a + // fabricated kitchen. + + /// The stable key a stop groups under for pickup. + static String sourceKeyOf(Map stop) { + final id = + (stop['sourceid'] ?? + stop['kitchenid'] ?? + stop['pickuplocationid'] ?? + '') + .toString() + .trim(); + if (id.isNotEmpty && id != '0') return 'id:$id'; + final name = sourceNameOf(stop).toLowerCase(); + if (name.isNotEmpty) return 'name:$name'; + return 'none'; + } + + /// The kitchen's name as the rider reads it, or '' when the payload has none. + /// **The** reader for a place's name. `stopSourceName` delegates here. + /// + /// ── Two readers, one question, different answers ── + /// + /// This read `sourcename` / `kitchenname`. `stopSourceName` read those *and* + /// the CamelCase `KitchenName` / `SourceName` the payload sometimes carries. + /// A booking with only the capitalised key therefore had a source according + /// to one function and none according to the other, and the route card used + /// both in the same expression: + /// + /// • `stopSourceName` said "there is a counter", so the group was **not** + /// flat — the header became a foldable place with its orders under it. + /// • `sourceNameOf` said "there is no counter", so `navigationLabel` fell + /// through to its leg description and the header was titled **"pickup"**. + /// + /// A group headed by the word *pickup* that folds open onto one order named + /// after the same stop — which is the shape the `flat` flag exists to + /// prevent, produced by the two halves of the decision disagreeing. + /// + /// One key list, one answer. + static String sourceNameOf(Map stop) => + (stop['sourcename'] ?? + stop['SourceName'] ?? + stop['kitchenname'] ?? + stop['KitchenName'] ?? + '') + .toString() + .trim(); + + /// True when two stops are collected from the same counter. + static bool sameSource(Map a, Map b) => + sourceKeyOf(a) == sourceKeyOf(b); + + /// Every stop collected from the same counter as [stop], out of [stops]. + /// + /// What the rider ticks at a kitchen, and the bound on what one pickup + /// operation may touch. + static List> sameSourceAs( + Map stop, + List> stops, + ) { + final key = sourceKeyOf(stop); + return [ + for (final s in stops) + if (sourceKeyOf(s) == key) s, + ]; + } + + // ══════════════════════════════════════════════════════════════════════ + // WHERE "NAVIGATE" GOES + // + // The same button on the same order means two different places depending on + // where the order is in its day, and getting it wrong is expensive in both + // directions: sending a rider to a customer for an order still sitting in a + // kitchen wastes the trip *and* the customer's slot, and sending him back to + // a kitchen for a bag already in his box is a ride to collect nothing. + // + // So the destination follows the stop's stage, in one place, rather than + // being decided by whichever screen happens to own the button. + // ══════════════════════════════════════════════════════════════════════ + + /// Whether this stop's next journey is to the customer rather than the + /// kitchen. + /// + /// True once it is collected. On a logistics booking this is always false: + /// that line's collected parcel goes to a hub and is delivered by somebody + /// else, so the rider never drives to its customer. + static bool navigatesToCustomer( + Map stop, { + Set collectedIds = const {}, + }) { + if (!ServiceProfile.active.deliversToCustomer) return false; + if (collectedIds.contains(idOf(stop))) return true; + final status = stopStatusOf(stop); + return status.isPicked || status.isDeliveryLeg; + } + + /// The kind of work this stop is **for the leg the rider is on**. + /// + /// ── Why [stopKindOf] is not enough on its own ── + /// + /// `stopKindOf` answers "what kind of stop is this?" from the payload, and the + /// payload is wrong about it on the one line where it matters most: the + /// booking adapter stamps `type: pickup` on every row it builds, because a v1 + /// booking *is* a first-mile pickup and the backend has no per-stop type to + /// send. See `ApiConfig.pickupFromBooking`. + /// + /// So a milk-run order the rider has already collected — a bag in his box, on + /// its way to a subscriber's door — still described itself as a pickup. The + /// card offered **Start Pickup**, the confirmation sheet asked him to + /// **Confirm pickup**, and the write path behind that button took the pickup + /// branch. He was being asked to collect something he was carrying. + /// + /// The leg is the honest question, and this app already knows how to answer + /// it: [navigatesToCustomer] is true exactly when the load is in his hands. + /// Everything the rider reads and everything the button posts should follow + /// that, not the stamped type. + /// + /// Returns [stopKindOf]'s answer unchanged on every other line and every + /// other stage, so a parcel booking is untouched. + static StopKind workingKind( + Map stop, { + Set collectedIds = const {}, + }) => navigatesToCustomer(stop, collectedIds: collectedIds) + ? StopKind.delivery + : stopKindOf(stop); + + /// Whether this stop's next rung is worked on **its own screen** — the map, + /// then I'VE ARRIVED, then the confirmation sheet — or in bulk on Home. + /// + /// ── The rule ── + /// + /// On **logistics**, every stop is worked on its own screen. The rider drives + /// to one customer, weighs one parcel, raises one shipment and takes one + /// payment; there is nothing to batch. + /// + /// On a **kitchen line** the pickup half is not a per-stop journey at all. He + /// makes one trip to one counter and is handed a stack of bags, so the rungs + /// up to *picked up* are a bulk gesture on Home — select the orders, slide + /// once — and the single-stop map/arrive/confirm screen is only ever the + /// **delivery** leg, one subscriber's door at a time. + /// + /// ── What it prevents ── + /// + /// A stop still to be collected could be opened on that screen from the + /// Deliveries tab's live strip. It took the rider through a map, an I'VE + /// ARRIVED and a confirmation sheet for a collection he was supposed to make + /// at the kitchen with everything else — a second, contradictory way to work + /// the same rung, on the tab that is supposed to be his load. The screens ask + /// this before they offer that route in. + static bool worksOnOwnScreen( + Map stop, { + Set collectedIds = const {}, + }) => + !ServiceProfile.active.handsOffAtCollection || + navigatesToCustomer(stop, collectedIds: collectedIds); + + /// The coordinates Navigate should open, or null when the stop carries none + /// for the leg it is on. + /// + /// Returns null rather than falling back to the other end: a Navigate button + /// that quietly opens the wrong destination is worse than one that is + /// disabled, because the rider only finds out when he arrives. + static ({double lat, double lng})? navigationTarget( + Map stop, { + Set collectedIds = const {}, + }) { + double? read(List keys) { + for (final k in keys) { + final v = double.tryParse('${stop[k] ?? ''}'); + if (v != null && v != 0) return v; + } + return null; + } + + if (navigatesToCustomer(stop, collectedIds: collectedIds)) { + final lat = read(['droplat', 'DropLat', 'deliverylatitude']); + final lng = read(['droplon', 'DropLon', 'deliverylongitude']); + if (lat == null || lng == null) return null; + return (lat: lat, lng: lng); + } + + final lat = read(['pickuplat', 'PickupLat', 'pickuplatitude']); + final lng = read([ + 'pickuplon', + 'pickuplong', + 'PickupLon', + 'pickuplongitude', + ]); + if (lat == null || lng == null) return null; + return (lat: lat, lng: lng); + } + + /// What that destination is called, for the button and the sheet. + static String navigationLabel( + Map stop, { + Set collectedIds = const {}, + }) { + if (navigatesToCustomer(stop, collectedIds: collectedIds)) { + final name = (stop['pickupcustomer'] ?? stop['PickupCustomer'] ?? '') + .toString() + .trim(); + return name.isEmpty ? 'customer' : name; + } + final kitchen = sourceNameOf(stop); + if (kitchen.isNotEmpty) return kitchen; + + // ── The word "pickup" is never a place ── + // + // This returned the literal `'pickup'`, and on a payload with no source + // name — which is what the live backend sends today — that word became + // the 24sp headline of the rider's first group. The project's own rule + // ("never display 'pickup' when a real name exists") was being met to the + // letter and lost in spirit: no name existed, so a leg description was + // promoted to a title. + // + // A rider thinks in PLACES, and the payload still knows one: the pickup + // address. Its first non-numeric component is the neighbourhood — the + // same reading the timeline's area line uses — and "RS Puram" is + // something he can ride to in a way "pickup" is not. Only when the + // payload has no address either does the label fall back to a word, and + // then it is at least a capitalised noun. + for (final key in const ['pickupaddress', 'PickupAddress']) { + final raw = (stop[key] ?? '').toString().trim(); + if (raw.isEmpty) continue; + for (final part in raw.split(',')) { + final p = part.trim(); + if (p.isEmpty) continue; + // Skip a leading door/plot number — the rider navigates by the + // neighbourhood, not by "12/4". + if (RegExp(r'^[0-9/\-]+$').hasMatch(p)) continue; + // And when the component carries its own house number — "124 + // Gandhipuram Main Road" — the number is still not the place. Strip + // the leading digit token and title the street. + return p.replaceFirst(RegExp(r'^[0-9][0-9/\-]*\s+'), ''); + } + } + return 'Pickup'; + } + + /// Stops still owing the rider a collection at a given counter. + /// + /// A stop counts as outstanding when it has been accepted and is not yet in + /// his hands. Anything he has been told he will not get — not loaded by the + /// source, cancelled, rejected — is not outstanding: it is settled, and + /// holding the round open for it would strand him at a counter waiting for a + /// bag that is not coming. + static List> outstandingPickups( + List> stops, { + required Set acceptedIds, + required Set collectedIds, + Set notLoadedIds = const {}, + Set rejectedIds = const {}, + }) { + final out = >[]; + for (final stop in stops) { + final id = idOf(stop); + if (id.isEmpty) continue; + if (collectedIds.contains(id)) continue; + if (notLoadedIds.contains(id)) continue; + if (rejectedIds.contains(id)) continue; + + final status = stopStatusOf(stop); + if (status.isCancelled || status.isRejected) continue; + // Already down the delivery leg — collected on a previous session whose + // local set was cleared. Not outstanding. + if (status.isDeliveryLeg || status.isPicked) continue; + + // Only work he has taken on holds the round open. An un-accepted stop is + // an offer, and a rider must not be blocked from starting his round by + // work he never agreed to. + if (!acceptedIds.contains(id) && !status.isFinishedPickup) { + if (status.isPending) continue; + } + out.add(stop); + } + return out; + } + + /// Where a stop is on the milk-run ladder, from the app's own records. + /// + /// The local sets lead the server here, deliberately: the queue endpoints are + /// a poll or more behind the rider and a card that ignores what he just did + /// reads as a button that did nothing. The backend status is the tie-breaker + /// underneath, not the first word. + static StopStatus stageOf( + Map stop, { + required Set acceptedIds, + required Set collectedIds, + Set outForDeliveryIds = const {}, + Set deliveredIds = const {}, + }) { + final id = idOf(stop); + final reported = stopStatusOf(stop); + + if (deliveredIds.contains(id) || reported.isDelivered) { + return StopStatus.delivered; + } + if (reported == StopStatus.deliveryArrived) { + return StopStatus.deliveryArrived; + } + // ── `Out_for_Delivery` is the consignment's state, not the rider's ── + // + // `pickup-complete` releases hyperlocal work itself: pickup and delivery + // pincodes sharing a 3-digit prefix means the parcel never sees a hub, so + // the consignment is stamped `Out_for_Delivery` in the same call that + // records the collection. Every DailyGrubs order is hyperlocal, so that is + // *always* what the next poll reports after a successful **Picked**. + // + // Reading it as this rung skipped `picked` entirely: the rider slid to + // confirm a hand-over at the counter and the ladder jumped straight to the + // delivery-active rung — before he had left the kitchen, let alone started + // the round. He never saw the state he had just created. + // + // So the released consignment resolves to [StopStatus.picked], and the + // delivery-active rung is the rider's own: [outForDeliveryIds] is written + // when he opens the stop and sets off. Nothing is faked and no status is + // invented — the two facts were simply being read as one. `deliveryArrived` + // and `delivered` are checked above and still win, so a stop the backend + // genuinely reports further along is never dragged back to picked. + if (outForDeliveryIds.contains(id)) return StopStatus.outForDelivery; + if (collectedIds.contains(id) || + reported.isPicked || + reported == StopStatus.outForDelivery) { + return StopStatus.picked; + } + if (reported == StopStatus.arrived) return StopStatus.arrived; + if (acceptedIds.contains(id) || reported == StopStatus.accepted) { + return StopStatus.accepted; + } + return reported; + } + + /// The button a stop's current stage offers, in the rider's words, or null + /// when the stop is waiting on something other than him. + static String? nextActionLabel(StopStatus stage) => switch (stage) { + StopStatus.accepted => 'Arrived', + StopStatus.arrived => 'Picked up', + StopStatus.outForDelivery => 'Arrived', + StopStatus.deliveryArrived => 'Delivered', + _ => null, + }; +} diff --git a/lib/data/mock_backend.dart b/lib/data/mock_backend.dart new file mode 100644 index 0000000..2ca040d --- /dev/null +++ b/lib/data/mock_backend.dart @@ -0,0 +1,182 @@ +import 'package:flutter/foundation.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE APP WITH NO BACKEND BEHIND IT +/// +/// One switch that takes the whole app off the network: sign-in, the day's +/// work, and every status the rider writes. It exists so the flow can be built, +/// demonstrated and judged without `api.doormile.com` answering — on a bench, on +/// a plane, or against a backend that does not have the meal contract yet. +/// +/// ── This app has been burned by a mock layer before ── +/// +/// One was ripped out for a good reason: it wrote rows into the same +/// SharedPreferences stores the real work uses, so demo stops surfaced on live +/// riders' tabs weeks later with nothing left in the repo to explain them. +/// `purgeDemoRecords()` still runs at every launch to clean up after it. +/// +/// Four rules keep this one from becoming that: +/// +/// 1. **It cannot ship.** [enabled] is false in a release build unless someone +/// types `--dart-define=MOCK_BACKEND=true` on the build command, which is +/// not something that happens by accident. Debug and profile builds — the +/// ones a developer actually runs — get it by default. +/// 2. **It intercepts the transport, not the stores.** Everything is served as +/// an *API response*; nothing writes to a store that real work shares. The +/// app cannot tell the difference and neither can its data layer. +/// 3. **Its ids are self-identifying.** Every record is `MOCK-…`, which is +/// exactly what `purgeDemoRecords()` looks for, so anything that does leak +/// into a store is removed at the next launch by code that already ships. +/// 4. **It says so, loudly.** Every intercepted call is logged with a `[MOCK]` +/// prefix, so a confusing session is one glance at the console away from +/// being explained. +/// +/// ── What is given up while it is on ── +/// +/// The hub is not told anything. A rider can accept, arrive, pick up and +/// deliver, and the console will show none of it — every write is answered with +/// a success that was manufactured on the phone. That is the correct trade for +/// design and demo work and the wrong one for a real shift, which is what rule 1 +/// is for. +/// ───────────────────────────────────────────────────────────────────────── + +/// The one switch. +/// +/// Default: **on** in debug and profile, **off** in release. Override either +/// way from the build command: +/// +/// flutter run # mock +/// flutter run --dart-define=MOCK_BACKEND=false # real API +/// flutter build apk --dart-define=MOCK_BACKEND=true # deliberate demo +/// ── Opt in, not opt out ── +/// +/// This defaulted to **on** in debug and profile, so every `flutter run` served +/// a canned rider, a canned day and a canned login — and the backend was only +/// ever exercised by someone who remembered to turn the mock off. Three +/// consequences, all of which actually happened: +/// +/// • A rider signing in with his real number became the fixture's rider. +/// • A whole class of contract bugs — a field renamed, a number sent where a +/// string was required — could not be seen, because nothing left the device. +/// • The default development experience diverged from production silently, +/// which is the one kind of divergence nobody notices until release. +/// +/// The canned day is still one flag away, and is genuinely useful on a bench or +/// a plane. It is just no longer what you get by not deciding: +/// +/// flutter run # the real API +/// flutter run --dart-define=MOCK_BACKEND=true # the canned day +const bool kMockBackend = bool.fromEnvironment('MOCK_BACKEND'); + +/// Canned answers for the routes the rider's day passes through. +/// +/// Everything else gets a generic success, on purpose: the alternative is a +/// mock that fails on an endpoint nobody thought about and reads to the person +/// using it as a broken app rather than an unmapped route. +class MockBackend { + MockBackend._(); + + /// Whether the canned answers are being served. + /// + /// Settable so a test can exercise the transport itself — the interception + /// happens *above* the HTTP client, so with this on there is no request to + /// assert against. Nothing in the app assigns it; it follows [kMockBackend]. + static bool enabled = kMockBackend; + + /// The signed-in rider, when there is no server to ask. + /// + /// `tenantname` is what puts the build on the meal line — see + /// [ServiceProfile] — so the mock day and the mock login agree about which + /// application the rider is in. + static const Map rider = { + 'userid': 90001, + 'id': 90001, + 'name': 'Suresh Kumar', + 'phone': '9876543210', + 'mobile': '9876543210', + 'email': 'suresh@doormile.test', + 'roleid': 5, + 'configid': 1001, + 'tenantid': 916, + 'tenantname': 'DailyGrubs', + 'status': 'Active', + 'vehicletype': 'Bike', + 'vehicleno': 'TN 37 CV 4412', + }; + + /// A token that is obviously not a JWT, so nothing tries to read claims out + /// of it and no log line makes it look like a real session. + static const String token = 'MOCK-TOKEN-not-a-real-session'; + + /// Answers [method] [path], or null when the caller should fall through to + /// the network. Null is never returned while [enabled] — see the class note. + static Map? respond( + String method, + String path, { + Object? body, + Map? query, + }) { + if (!enabled) return null; + + final route = path.split('?').first; + debugPrint('[MOCK] $method $route'); + + // ── Auth ── + if (route.endsWith('/login')) { + return _ok({'otpsent': true, 'phone': _field(body, 'phone')}); + } + if (route.endsWith('/verify-pin')) { + // The shape the real handler returns, verified against a live 200: the + // profile lives under `user` / `user.profile`, and there is no `data` + // key. Getting this wrong cost a debugging session once already — see the + // note in [MilerApi]. + // + // The phone is the one the rider actually typed. It used to be the + // fixture's, so signing in with your own number made you Suresh Kumar and + // the screen you landed on disagreed with the number on the screen you + // came from — which reads as a broken login rather than as a mock. + final phone = _field(body, 'phone'); + final signedIn = { + ...rider, + if (phone.isNotEmpty) ...{ + 'phone': phone, + 'mobile': phone, + 'contactno': phone, + }, + }; + return { + 'success': true, + 'message': 'ok', + 'token': token, + 'user': {...signedIn, 'profile': signedIn}, + }; + } + if (route.endsWith('/profile')) return _ok(rider); + + // ── The day's work ── + // + // Empty rather than invented: the meal line's stops come from + // [MealRunMock], which the provider reads *before* it reaches the network + // at all. A parcel booking list has no fixture and inventing one here would + // put fictional consignments in front of a parcel rider. + if (route.endsWith('/bookings') || route.endsWith('/assignments')) { + return _ok(const []); + } + + // ── Everything the rider writes ── + // + // Accept, reject, reached, parcels, payment, pickup-complete, deliver, + // skip, duty, breaks, telemetry, device tokens. All answered yes, and all + // going nowhere. + return _ok(const {}); + } + + static Map _ok(dynamic data) => { + 'success': true, + 'message': 'ok', + 'data': data, + }; + + static String _field(Object? body, String key) => + (body is Map && body[key] != null) ? body[key].toString() : ''; +} diff --git a/lib/data/mutation_guard.dart b/lib/data/mutation_guard.dart new file mode 100644 index 0000000..f665808 --- /dev/null +++ b/lib/data/mutation_guard.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE PRESS, ONE REQUEST +/// +/// Every business-critical action in this app is a POST that the backend is not +/// idempotent about: `accept`, `reject`, `reached`, `parcel`, `payment`, +/// `pickup-complete`, `deliver`, `skip`, `duty/start`, `duty/end`, +/// `breaks/start`, `breaks/end`, `availability`. +/// +/// A rider presses those on a moving bike, with gloves, on a screen he cannot +/// look at for long — so a double tap is not an edge case, it is Tuesday. And +/// the failure is not cosmetic: two `duty/start` calls are an error the server +/// rejects, two `payment` calls are two payments, and two `accept` calls race +/// each other to decide what the list shows next. +/// +/// The screens each solved this, or did not, with their own `_busy` flag — +/// which works until the widget rebuilds, the callback is captured twice, or the +/// action is reachable from two places (a card and a sheet) that do not share a +/// flag. This is one place that solves it once, keyed by the thing being +/// mutated rather than by the widget that happens to be showing it. +/// +/// ── What this is not ── +/// +/// Not a queue and not a retry. A second press while the first is in flight is +/// **dropped**, not deferred: the rider meant one thing, and running it twice a +/// second later is the bug, not the fix. Not a cache either — once the call +/// completes the key is free, so a genuine second accept of the same stop after +/// a failure still goes through. +/// ───────────────────────────────────────────────────────────────────────── +class MutationGuard { + MutationGuard._(); + + /// Keys with a request currently in flight. + static final Set _inFlight = {}; + + /// True while [key] has a mutation running. + /// + /// Read by buttons so they can show the disabled/loading state that makes the + /// dropped second press *visible* rather than merely harmless. + static bool isBusy(String key) => _inFlight.contains(key); + + /// Runs [action] unless [key] is already running, in which case it returns + /// null and does nothing. + /// + /// The key names the **resource and the verb**, not the screen: + /// `accept:1042`, `deliver:77`, `duty`. Two widgets showing the same stop + /// therefore share one guard, which is the whole point. + static Future run(String key, Future Function() action) async { + if (_inFlight.contains(key)) { + debugPrint('[GUARD] dropped duplicate "$key" — one is already running'); + return null; + } + _inFlight.add(key); + try { + return await action(); + } finally { + // `finally`, so a thrown request frees its key. A guard that leaks on + // failure is worse than no guard: the rider's retry would be silently + // dropped for the rest of the session. + _inFlight.remove(key); + } + } + + /// Clears every key. For tests, and for sign-out — a new session must not + /// inherit the last one's in-flight set. + static void reset() => _inFlight.clear(); +} diff --git a/lib/data/order_events.dart b/lib/data/order_events.dart new file mode 100644 index 0000000..5a7cadc --- /dev/null +++ b/lib/data/order_events.dart @@ -0,0 +1,200 @@ +/// ───────────────────────────────────────────────────────────────────────── +/// WHEN EACH THING HAPPENED TO AN ORDER +/// +/// ── Why this exists ── +/// +/// Activity could show three moments — set off, arrived, finished — because +/// those were the only three anything wrote down. Everything else the rider did +/// to an order happened, changed a screen, went to the backend and left no +/// local trace: he accepted it at 9:10, reached the kitchen at 9:28, took the +/// bag at 9:35, and by the time he opened his own history all three were gone. +/// +/// The backend knows some of it and returns none of it: `GET /miler/bookings` +/// carries a booking's *current* status and its `createdat`, and there is no +/// per-order event feed on the contract. So a rider asking "when did I accept +/// that?" had nothing to read, and the honest timeline was three rungs long. +/// +/// This is the ledger those moments go into. Each stamp is written **at the +/// moment it is true**, by the code that already knows — the same argument +/// `addCompletedBookings` makes for the two clocks it rescues out of +/// SharedPreferences. +/// +/// ── What it is not ── +/// +/// Not a source of truth about the order, and not a substitute for one. The +/// backend owns the lifecycle; this owns *when the rider did his half of it*, +/// for the one screen that asks. Nothing reads it to decide anything — no gate, +/// no filter, no status. If the file were deleted the app would behave +/// identically and Activity would draw fewer rungs, which is exactly the +/// degradation it is built for: **a moment with no stamp is not drawn.** +/// +/// ── Written once ── +/// +/// [stampOrderEvent] never overwrites. A rider who re-accepts a stop he +/// un-rejected, or re-opens a map screen, is on the same errand; moving the +/// clock forward would quietly erase the first time he did it. The one +/// exception is a resumed skip, which is a genuinely new attempt — see +/// [clearOrderEvents]. +/// ───────────────────────────────────────────────────────────────────────── +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// The moments an order passes through, in the order they happen. +/// +/// String values, not an index: they are written into JSON that outlives the +/// build that wrote it, and renumbering an enum would silently re-label every +/// stamp already on the device. +class OrderEvent { + OrderEvent._(); + + /// The booking first appeared in this rider's queue. + /// + /// Not the hub's assignment clock — the contract has none, and the booking's + /// `updatedat` moves every time anything touches the row. This is when the + /// work *reached him*, stamped by [WorkRepository] on the first fetch that + /// carries it, which is the moment it became his as far as his phone is + /// concerned. + static const String assigned = 'assigned'; + + /// The rider took the stop on. `POST /assignments/:id/accept`. + static const String accepted = 'accepted'; + + /// He reached the counter. `POST /bookings/:id/reached`. + static const String arrivedAtPickup = 'arrived_at_pickup'; + + /// The bag is in his hands. `POST /bookings/:id/pickup-complete`. + static const String pickedUp = 'picked_up'; + + /// He set off on the round. `POST /miler/deliveries/start`. + static const String outForDelivery = 'out_for_delivery'; + + /// Every event, in the order a timeline should read them. + static const List inOrder = [ + assigned, + accepted, + arrivedAtPickup, + pickedUp, + outForDelivery, + ]; +} + +const String _kKey = 'order_events'; + +/// Every order's stamps: `{orderid: {event: iso8601}}`. +Future>> _read(SharedPreferences p) async { + try { + final raw = p.getString(_kKey); + if (raw == null || raw.isEmpty) return {}; + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return { + for (final entry in decoded.entries) + entry.key.toString(): { + if (entry.value is Map) + for (final e in (entry.value as Map).entries) + e.key.toString(): e.value.toString(), + }, + }; + } catch (e) { + // A corrupt ledger must never take the app down: this is the one store + // nothing depends on, so an unreadable one is an empty one. + debugPrint('[EVENTS] unreadable, starting empty: $e'); + return {}; + } +} + +/// Records [event] against [orderId], if it has not been recorded already. +Future stampOrderEvent( + Object orderId, + String event, { + DateTime? at, +}) async { + final id = orderId.toString().trim(); + if (id.isEmpty) return; + try { + final prefs = await SharedPreferences.getInstance(); + final all = await _read(prefs); + final mine = all[id] ?? {}; + // Written once — see the class doc. + if (mine.containsKey(event)) return; + mine[event] = (at ?? DateTime.now()).toIso8601String(); + all[id] = mine; + await prefs.setString(_kKey, jsonEncode(all)); + } catch (e) { + debugPrint('[EVENTS] could not stamp $event on $id: $e'); + } +} + +/// Stamps one event against several orders at once — a kitchen handover, an +/// accept-all, a released round. +Future stampOrderEvents(Iterable orderIds, String event) async { + final at = DateTime.now(); + for (final id in orderIds) { + await stampOrderEvent(id, event, at: at); + } +} + +/// What is known about one order, as `{event: iso8601}`. +Future> getOrderEvents(Object orderId) async { + final id = orderId.toString().trim(); + if (id.isEmpty) return const {}; + final prefs = await SharedPreferences.getInstance(); + return (await _read(prefs))[id] ?? const {}; +} + +/// Forgets an order's stamps. +/// +/// Called when a skipped stop is resumed: the rider is making a second attempt +/// at the same door, and the first attempt's clocks describe a visit that has +/// been superseded. Same argument `addCompletedBookings` makes when it clears +/// the two prefs keys it has just read. +Future clearOrderEvents(Object orderId) async { + final id = orderId.toString().trim(); + if (id.isEmpty) return; + try { + final prefs = await SharedPreferences.getInstance(); + final all = await _read(prefs); + if (all.remove(id) != null) { + await prefs.setString(_kKey, jsonEncode(all)); + } + } catch (e) { + debugPrint('[EVENTS] could not clear $id: $e'); + } +} + +/// Drops orders whose last stamp is older than [keepDays]. +/// +/// The ledger is per-order and nothing prunes it on read, so without this it +/// grows for the life of the install — the same trap `removeCollectedOrderIds` +/// exists to avoid. Two days rather than one, because a shift that crosses +/// midnight must not lose its own morning. +Future pruneOrderEvents({int keepDays = 2}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final all = await _read(prefs); + final cutoff = DateTime.now().subtract(Duration(days: keepDays)); + + final kept = >{}; + for (final entry in all.entries) { + DateTime? newest; + for (final iso in entry.value.values) { + final t = DateTime.tryParse(iso); + if (t != null && (newest == null || t.isAfter(newest))) newest = t; + } + // An entry with no parseable stamp in it is kept: it is either from a + // build that wrote a shape this one does not know, or corrupt, and + // deleting somebody's history to tidy up is the worse failure. + if (newest == null || newest.isAfter(cutoff)) + kept[entry.key] = entry.value; + } + if (kept.length != all.length) { + await prefs.setString(_kKey, jsonEncode(kept)); + } + } catch (e) { + debugPrint('[EVENTS] prune failed: $e'); + } +} diff --git a/lib/data/proof_store.dart b/lib/data/proof_store.dart new file mode 100644 index 0000000..ed6d57f --- /dev/null +++ b/lib/data/proof_store.dart @@ -0,0 +1,160 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/work_scope.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PROOF OF DELIVERY, KEPT WHERE IT SURVIVES +/// +/// A photo taken at a door is evidence, and evidence that disappears is worse +/// than none — it makes the rider believe he has cover he does not have. Two +/// things were therefore decided deliberately: +/// +/// **It is copied, not referenced.** `ImagePicker` hands back a file in the +/// OS *cache* directory, which Android reclaims whenever it likes and clears +/// outright on a storage sweep. Pointing the Activity record at that path +/// gives a record whose picture is gone by the end of the week. The file is +/// copied into the app's documents directory, which is the app's to keep. +/// +/// **It belongs to a scope.** Filed under the same rider/tenant/line identity +/// as every other record ([WorkScope]) so signing out or switching line does +/// not leave one rider's doorstep photos where the next one can open them. +/// +/// ── The limitation this cannot solve ── +/// +/// **There is no upload route on the Miler API.** `POST +/// /miler/consignments/:id/deliver` takes a `photourl` *string*, and nothing +/// in the contract accepts a file — no multipart route, no signed-URL +/// endpoint, no attachment on any other call. So the proof is real, durable +/// and auditable **on the handset**, and the hub cannot see it until the +/// backend grows somewhere to put it. The app deliberately does NOT send the +/// local path in `photourl`: a filesystem path from somebody's phone is not a +/// URL, and writing one into the delivery record would put a string in the +/// hub's database that looks like evidence and resolves to nothing. +/// [remoteUrlFor] is where a real URL will come from on the day that route +/// exists; until then it is honestly empty. +/// ───────────────────────────────────────────────────────────────────────── +class ProofStore { + ProofStore._(); + + static const String _indexKey = 'delivery_proof_paths'; + + /// Where the copies live, created on demand. + static Future _dir() async { + final base = await getApplicationDocumentsDirectory(); + final dir = Directory('${base.path}/delivery_proof'); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; + } + + static Future _key() async => + (await WorkScope.current()).scoped(_indexKey); + + /// Copies [sourcePath] into the app's own storage and records it against + /// [orderId]. Returns the durable path, or null when the copy fails. + /// + /// Failure is returned rather than thrown: a rider standing at a door with a + /// full disk still has to be able to complete the stop, and the delivery is + /// the thing that matters. The caller decides whether to proceed without it. + static Future save(String orderId, String sourcePath) async { + if (orderId.trim().isEmpty || sourcePath.trim().isEmpty) return null; + try { + final src = File(sourcePath); + if (!await src.exists()) return null; + + final dir = await _dir(); + // The order id is the natural name — one proof per stop, and a retake + // overwrites rather than accumulating. Sanitised because an id can carry + // characters a filename cannot. + final safe = orderId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + final dest = '${dir.path}/$safe.jpg'; + await src.copy(dest); + + final prefs = await SharedPreferences.getInstance(); + final key = await _key(); + final index = [...(prefs.getStringList(key) ?? const [])] + ..removeWhere((e) => e.startsWith('$orderId::')) + ..add('$orderId::$dest'); + await prefs.setStringList(key, index); + + debugPrint('[PROOF] $orderId saved to $dest'); + return dest; + } catch (e) { + debugPrint('[PROOF] could not save proof for $orderId: $e'); + return null; + } + } + + /// The stored proof for [orderId], or null when there is none **or the file + /// has gone**. A path that no longer resolves is not proof, and returning it + /// would draw a broken image in place of evidence. + static Future pathFor(String orderId) async { + if (orderId.trim().isEmpty) return null; + try { + final prefs = await SharedPreferences.getInstance(); + final index = prefs.getStringList(await _key()) ?? const []; + for (final entry in index) { + final i = entry.indexOf('::'); + if (i <= 0) continue; + if (entry.substring(0, i) != orderId) continue; + final path = entry.substring(i + 2); + return await File(path).exists() ? path : null; + } + } catch (e) { + debugPrint('[PROOF] could not read proof for $orderId: $e'); + } + return null; + } + + /// The **remote** reference for [orderId], for `deliver`'s `photourl`. + /// + /// Always empty today: the Miler API has no route that turns a file into a + /// URL. Kept as the single seam so that when one exists, the delivery call + /// starts carrying a real link without any other code changing — and so + /// that nobody is tempted to pass a device path in the meantime. + static Future remoteUrlFor(String orderId) async => ''; + + /// Forgets proofs whose orders are long finished, so the directory cannot + /// grow for the life of the install. Best-effort. + static Future prune({int keep = 200}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = await _key(); + final index = prefs.getStringList(key) ?? const []; + if (index.length <= keep) return; + + final drop = index.take(index.length - keep).toList(); + for (final entry in drop) { + final i = entry.indexOf('::'); + if (i <= 0) continue; + final f = File(entry.substring(i + 2)); + if (await f.exists()) await f.delete(); + } + await prefs.setStringList(key, index.sublist(index.length - keep)); + } catch (e) { + debugPrint('[PROOF] prune failed: $e'); + } + } + + /// Drops every proof in this scope. Called from logout alongside the other + /// scoped stores — a doorstep photo is exactly the kind of record that must + /// not outlive the session that took it. + static Future clearScope() async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = await _key(); + for (final entry in prefs.getStringList(key) ?? const []) { + final i = entry.indexOf('::'); + if (i <= 0) continue; + final f = File(entry.substring(i + 2)); + if (await f.exists()) await f.delete(); + } + await prefs.remove(key); + } catch (e) { + debugPrint('[PROOF] could not clear scope: $e'); + } + } +} diff --git a/lib/data/rider_tier.dart b/lib/data/rider_tier.dart index 80bca5d..878ae97 100644 --- a/lib/data/rider_tier.dart +++ b/lib/data/rider_tier.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; @@ -66,10 +67,10 @@ extension RiderTierUi on RiderTier { int get threshold => kTierThresholds[this] ?? 0; IconData get icon => switch (this) { - RiderTier.blue => Icons.pedal_bike_rounded, - RiderTier.silver => Icons.military_tech_rounded, - RiderTier.gold => Icons.workspace_premium_rounded, - RiderTier.platinum => Icons.diamond_rounded, + RiderTier.blue => LucideIcons.bike, + RiderTier.silver => LucideIcons.medal, + RiderTier.gold => LucideIcons.award, + RiderTier.platinum => LucideIcons.gem, }; /// Colour of the badge, not of the page. The card behind it stays brand diff --git a/lib/data/route_order.dart b/lib/data/route_order.dart new file mode 100644 index 0000000..562006f --- /dev/null +++ b/lib/data/route_order.dart @@ -0,0 +1,218 @@ +import 'package:flutter/foundation.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE ADMIN'S ROUTE IS THE ROUTE +/// +/// One rule, in one place, for the only question that decides what order a +/// rider works his stops in: **has the hub solved this route?** +/// +/// If yes — that order is used, exactly, on both legs. The app does not +/// improve it, shorten it, or reorder it by where the rider +/// happens to be standing. +/// If no — the app falls back, and *says* it is falling back. +/// +/// ── Why this needed taking out of the screens ── +/// +/// It was answered in two places that could not see each other. +/// `Trip.sortStops` — the pickup leg — reads `step` and documents it as +/// authoritative. The Deliveries tab had its own sort: stops with a step +/// first, then everything else **by straight-line distance from the rider's +/// current GPS fix**. Since `step` reached the app only through +/// `GET /miler/assignments`, and that endpoint is deliberately the *active* +/// queue (`Assigned`/`Accepted` only), a booking lost its step at the moment +/// it was collected — which is exactly when the delivery leg starts. +/// +/// So every delivery was ordered nearest-first, the hub believed its solved +/// sequence was being followed, and nothing on either side said otherwise. +/// A rider re-optimising a route the hub has planned is not a small +/// difference: it changes promised arrival windows the customer was given. +/// +/// ── The fallback is a fallback ── +/// +/// Proximity ordering is not wrong in itself — with no assigned sequence the +/// app has to pick something, and nearest-first is the best guess available. +/// What is wrong is presenting a guess as the hub's plan. [RouteOrder.sort] +/// therefore returns *which rule it applied*, so a screen can label the queue +/// honestly and a test can assert the rule rather than the resulting order. +/// +/// ── One sequenced stop is enough ── +/// +/// If **any** stop in the set carries a sequence, the whole set is treated as +/// admin-ordered: the sequenced stops lead in their solved order, and the rest +/// follow. Mixing the two — sorting the sequenced ones by step and the others +/// by distance — is what the Deliveries tab used to do, and it produces a list +/// that is neither the hub's route nor a sane one. +/// ───────────────────────────────────────────────────────────────────────── +enum RouteOrderSource { + /// The hub solved it. `step` (or an equivalent) came down populated. + adminSequence, + + /// No sequence, but the stops carry booked times — work them in the order + /// they were promised for. + bookedTime, + + /// No sequence and no times. Whatever order the backend listed them in, + /// preserved rather than replaced. + backendOrder, + + /// No sequence, and the app chose nearest-first from the rider's position. + /// **A guess.** Must be labelled as one wherever it reaches a screen. + proximity, +} + +extension RouteOrderSourceX on RouteOrderSource { + /// True when the order came from the hub and must not be second-guessed. + bool get isAdmin => this == RouteOrderSource.adminSequence; + + /// What the rider is told the list is ordered by. Short, because it sits + /// under a heading and not in a paragraph. + String get label => switch (this) { + RouteOrderSource.adminSequence => 'Hub route', + RouteOrderSource.bookedTime => 'By booked time', + RouteOrderSource.backendOrder => 'As assigned', + RouteOrderSource.proximity => 'Nearest first', + }; + + /// The longer form, for a place with room to explain — and specifically to + /// keep the app from implying the hub planned an order it did not. + String get explanation => switch (this) { + RouteOrderSource.adminSequence => 'Ordered by the route your hub assigned.', + RouteOrderSource.bookedTime => + 'No route assigned — ordered by booked time.', + RouteOrderSource.backendOrder => + 'No route assigned — shown in the order they came through.', + RouteOrderSource.proximity => + 'No route assigned — ordered by what is closest to you.', + }; +} + +/// A stop's place in the hub's solved route, and the sort that honours it. +abstract final class RouteOrder { + /// Field names that have carried the solved sequence. + /// + /// `step` is the one the backend writes. The rest are spellings seen in the + /// wild or named in the contract for the delivery leg; reading all of them + /// costs nothing and means a rename does not silently drop the whole rule + /// back to nearest-first — the failure mode that started this. + static const List sequenceKeys = [ + 'step', + 'Step', + 'deliverystep', + 'deliveryStep', + 'routestep', + 'routeStep', + 'sequence', + 'routesequence', + 'routeSequence', + 'stopsequence', + ]; + + /// This stop's place in the route, or `0` for *not sequenced*. + /// + /// **Zero is not "first".** The hub numbers from 1, so a 0 means the + /// optimizer has not run for this stop, and treating it as position zero + /// would put unsequenced work at the head of a solved route. + static int sequenceOf(Map stop) { + for (final key in sequenceKeys) { + final raw = stop[key]; + if (raw == null) continue; + final n = raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0; + if (n > 0) return n; + } + return 0; + } + + /// True when the hub has solved an order for at least one of these stops. + static bool hasAdminSequence(Iterable> stops) => + stops.any((s) => sequenceOf(s) > 0); + + /// Puts [stops] in the order they are to be worked, and says which rule it + /// used. + /// + /// [distanceTo] is the escape hatch for the proximity fallback: the caller + /// supplies metres from the rider to a stop, because this file is pure and + /// has no business knowing about GPS. Omit it and proximity is simply never + /// used — which is the correct behaviour with no fix available, not a + /// reason to leave the list unsorted. + static (List>, RouteOrderSource) sort( + List> stops, { + double Function(Map stop)? distanceTo, + DateTime? Function(Map stop)? bookedTimeOf, + }) { + if (stops.length <= 1) { + return ( + List>.from(stops), + hasAdminSequence(stops) + ? RouteOrderSource.adminSequence + : RouteOrderSource.backendOrder, + ); + } + + // Indexed so every comparison can fall back to the order the backend sent, + // which makes the sort stable and keeps "unordered" meaning *unchanged*. + final indexed = <(int, Map)>[ + for (var i = 0; i < stops.length; i++) (i, stops[i]), + ]; + + if (hasAdminSequence(stops)) { + indexed.sort((a, b) { + final sa = sequenceOf(a.$2); + final sb = sequenceOf(b.$2); + if (sa > 0 && sb > 0 && sa != sb) return sa - sb; + // Sequenced work leads. An unsequenced stop appended to a solved route + // is a stop the hub did not plan for, and it goes at the end of it. + if (sa > 0 && sb == 0) return -1; + if (sb > 0 && sa == 0) return 1; + return a.$1 - b.$1; + }); + return ([for (final e in indexed) e.$2], RouteOrderSource.adminSequence); + } + + // ── No admin route. Only now may the app choose. ── + final times = {}; + if (bookedTimeOf != null) { + for (final e in indexed) { + final t = bookedTimeOf(e.$2); + if (t != null) times[e.$1] = t; + } + } + + if (times.length > 1) { + indexed.sort((a, b) { + final ta = times[a.$1]; + final tb = times[b.$1]; + if (ta != null && tb != null && ta != tb) return ta.compareTo(tb); + if (ta != null && tb == null) return -1; + if (tb != null && ta == null) return 1; + return a.$1 - b.$1; + }); + return ([for (final e in indexed) e.$2], RouteOrderSource.bookedTime); + } + + if (distanceTo != null) { + final metres = {for (final e in indexed) e.$1: distanceTo(e.$2)}; + indexed.sort((a, b) { + final da = metres[a.$1] ?? double.infinity; + final db = metres[b.$1] ?? double.infinity; + if (da != db) return da.compareTo(db); + return a.$1 - b.$1; + }); + return ([for (final e in indexed) e.$2], RouteOrderSource.proximity); + } + + return ([for (final e in indexed) e.$2], RouteOrderSource.backendOrder); + } + + /// Records that a set of stops reached a screen with no assigned order. + /// + /// Not a warning about a bug in this app — it is the hub's optimizer not + /// having run. Logged so the gap is visible in a rider's log rather than + /// inferred later from a complaint about stop order. + static void logUnsequenced(String where, int count) { + if (count <= 0) return; + debugPrint( + '[ROUTE][$where] $count stop(s) with no assigned sequence — ' + 'ordering is the app\'s own fallback, not the hub\'s route', + ); + } +} diff --git a/lib/data/service_day.dart b/lib/data/service_day.dart new file mode 100644 index 0000000..cd2f35f --- /dev/null +++ b/lib/data/service_day.dart @@ -0,0 +1,95 @@ +/// ───────────────────────────────────────────────────────────────────────── +/// THE OPERATING DAY +/// +/// Activity is a **shift log**, not an archive. What the rider needs from it is +/// "what have I done today" — and today ends when his day ends, not when a +/// timer somewhere fires. +/// +/// ── Why this is a filter and not a cleanup job ── +/// +/// The obvious build is a scheduled purge: at midnight, delete yesterday. It +/// is also the one that cannot work on a phone. The app is closed at midnight +/// far more often than it is open; a timer that must fire at 23:59 to keep the +/// screen correct is a timer that will not fire, and the rider opens Activity +/// at six the next morning to yesterday's finished work presented as today's. +/// Worse, a purge is destructive on a schedule nothing observes — one bad +/// timezone assumption and it takes the current shift with it. +/// +/// So nothing is deleted on a clock. Every record carries the service day it +/// belongs to, and the screen asks for **today's**. When the date rolls over, +/// yesterday stops matching; it does not need to be removed, and it is still +/// there for anything that legitimately wants history. Closing the app, force +/// quitting it, flying through a timezone or leaving it open across midnight +/// all produce the same answer, because the answer is computed at read time. +/// +/// ── Local, deliberately ── +/// +/// A rider's day is the day where he is standing. `DateTime.now()` is already +/// local, and both stores stamp their records with the local calendar date at +/// the moment of writing — so a record written at 23:50 belongs to that day +/// and a record written ten minutes later belongs to the next, which is what +/// the rider would say too. Nothing here converts to UTC: doing so would move +/// the boundary to 05:30 local in IST and split every evening shift in half. +/// ───────────────────────────────────────────────────────────────────────── +abstract final class ServiceDay { + /// `2026-08-21` for [at], in the local calendar. The same shape both stores + /// write, so a stamp and a query can be compared as strings. + static String stamp(DateTime at) => + '${at.year.toString().padLeft(4, '0')}-' + '${at.month.toString().padLeft(2, '0')}-' + '${at.day.toString().padLeft(2, '0')}'; + + /// The service day the rider is in right now. + static String get today => stamp(DateTime.now()); + + /// The stamps a record carries, newest-meaning first. + static const List dayKeys = [ + 'completedday', + 'skippedday', + 'serviceday', + ]; + + /// The timestamps to fall back on when a row carries no day stamp — an API + /// row, say, which has never been through either local store. + static const List timeKeys = [ + 'completedat', + 'skippedat', + 'deliveredat', + 'updatedat', + 'createdat', + ]; + + /// The service day [row] belongs to, or `''` when it carries nothing usable. + /// + /// Empty is a real answer and callers must decide what it means for them — + /// see [belongsToToday], which keeps such a row rather than dropping it. + static String of(Map row) { + for (final k in dayKeys) { + final v = (row[k] ?? '').toString().trim(); + if (v.length >= 10) return v.substring(0, 10); + } + for (final k in timeKeys) { + final raw = (row[k] ?? '').toString().trim(); + if (raw.isEmpty) continue; + final t = DateTime.tryParse(raw); + if (t != null) return stamp(t.isUtc ? t.toLocal() : t); + } + return ''; + } + + /// Whether [row] belongs to the service day in progress. + /// + /// ── A row with no date is kept, and kept quietly ── + /// + /// It is on this screen because this session produced it or the API returned + /// it for this rider; the missing field is a data-quality problem, not + /// evidence that the work happened yesterday. Dropping it would silently + /// lose a rider's completed stop, and labelling it — the old `Undated` badge + /// — puts an internal defect on a screen he is supposed to read at a glance + /// and can do nothing about. So it stays, in today, unmarked. + static bool belongsToToday(Map row, {String? now}) { + final day = of(row); + if (day.isEmpty) return true; + return day == (now ?? today); + } +} diff --git a/lib/data/service_profile.dart b/lib/data/service_profile.dart new file mode 100644 index 0000000..82a9c5a --- /dev/null +++ b/lib/data/service_profile.dart @@ -0,0 +1,700 @@ +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHICH LINE OF WORK THIS RIDER IS ON +/// +/// One company — **Doormile** — running two lines of work out of one rider app. +/// +/// • **Parcel.** First-mile logistics: collect a consignment from a customer, +/// carry it to the hub, cash on delivery, proof at every door. The app's +/// original business. +/// +/// • **Meals.** Subscription food runs for clients with cloud kitchens: load a +/// crate of lunches at the kitchen, drop them to subscribers who have already +/// paid by the month, go home. No money at any door, no chain of custody, a +/// different shape of day entirely. +/// +/// The two used to be two separate rider apps. They are one app now, and which +/// line a rider is on is decided by the **tenant id on his login** — the hub +/// assigns it, the app reads it once and never asks again. +/// +/// ── Why the tenant and not the trip ── +/// +/// The earlier design note for mixed routes argued for keying behaviour off the +/// *stop*, because one rider can hold a pickup and a delivery in the same hour. +/// That argument does not apply here: the two lines have separate riders, +/// separate hubs and separate clients. A rider belongs to one of them for the +/// life of his account, and `tenantid` is already on the login response — so it +/// resolves once, at login, and nothing downstream has to ask again. +/// +/// ── The one rule for using this ── +/// +/// Screens read a **capability**, never a line. `if (profile.collectsCash)`, +/// not `if (line == ServiceLine.meals)`. Capabilities are why signing a third +/// kind of client later is a new [ServiceProfile] rather than a third UI: the +/// day a food client wants cash on delivery, or a parcel client wants crate +/// loading, the screens are already asking the right question. +/// ───────────────────────────────────────────────────────────────────────── +enum ServiceLine { + /// Parcel logistics. The app's original and largest business. + parcel, + + /// **Milk Man delivery** — load a crate at a source (a kitchen), then drop + /// to the customers who ordered from it. + /// + /// Named `milkMan` after the operation the hub runs; the code called this + /// `meals` while it had one food client, and the shape of the work — collect + /// in bulk from one or more sources, then a round of prepaid drops — is the + /// same whatever is in the crate. + milkMan, + + /// Last-mile delivery: carry consignments out of the hub and hand them to + /// customers. The Xpress-rider app's business. + /// + /// ── Why this one is different from the other two ── + /// + /// [parcel] and [meals] are the *same screens* with different capabilities + /// switched on. This one is not: it has its own screens, ported verbatim from + /// the Xpress-rider app and living under `lib/xpress`. A delivery rider gets + /// that app — its home, its deliveries list, its summary, its four-tab bottom + /// bar — wearing Doormile's colours. + /// + /// So the capabilities on [ServiceProfile.delivery] are close to decorative: + /// almost nothing reads them, because the delivery screens do not ask the + /// capability questions the parcel screens ask. What this line is *for* is the + /// routing decision in `helpers/rider_shell.dart`, which is the only place + /// that has to know. + delivery, +} + +/// The moment a stop leaves Home for the work tab. See [ServiceProfile.handoffAt]. +enum HandoffPoint { + /// As soon as the rider accepts it. + accepted, + + /// Only once he is physically carrying it. + collected, +} + +/// What this line of work requires of the rider at the door. +@immutable +class ServiceProfile { + final ServiceLine line; + + /// Shown wherever the rider needs to know which work this is. + /// + /// Both lines are Doormile — the rider's employer does not change with the + /// crate he is carrying — so this names the *work*, not a company. + final String label; + + /// ── The one noun the whole app uses for a job ── + /// + /// The two rider apps this one replaces disagreed about what a job is called: + /// the parcel app said **booking**, the meal app said **delivery**. Merging + /// them meant picking one, and there is no single honest answer — a rider who + /// collects parcels for the hub does not "deliver" them, and a rider handing + /// somebody lunch has not taken a "booking". + /// + /// So the noun follows the line, and every string that names a job reads it + /// from here: the tab in the nav bar, the accepted-count pill, the sentence on + /// a taken stop, the empty states. A rider only ever belongs to one line, so + /// he only ever sees one word — and the code has one path, not two. + /// + /// Capitalised where a label needs it; see [workTabLabel]. + final String jobNoun; + + /// Plural of [jobNoun] — English is not regular enough to derive it. + final String jobNounPlural; + + /// What the nav bar calls the tab holding accepted work. + /// + /// "Bookings" on a parcel route, "Deliveries" on a meal run. Title case + /// because it is a proper destination in the app, not a word in a sentence. + String get workTabLabel => + jobNounPlural[0].toUpperCase() + jobNounPlural.substring(1); + + /// The verb for finishing a stop, in the rider's voice: "Picked up" when he + /// is taking something away, "Delivered" when he is handing it over. + /// + /// A meal run's stops are all drops, so its rider never sees "Picked up" at a + /// customer's door — that word belongs to the kitchen, where he loads. + final String completionVerb; + + /// Money changes hands at the stop. False for a subscription service — the + /// client is billed monthly, so the rider never sees an amount. + /// + /// Read through [stopCollectionAmount], which returns 0 when this is false, + /// so every existing "is anything owed?" branch already does the right thing. + final bool collectsCash; + + /// The full proof-of-work page — parcel ticks, weight, condition, OTP, + /// photo, review. Right for a parcel with a chain of custody; wrong for a + /// lunch box, where it is a two-minute form standing between a rider and a + /// doorbell. + /// + /// **Nothing reads this today.** The page is off on both tenants: arriving + /// goes straight to the confirmation sheet, which asks the one question a + /// stop actually has an answer to — picked up, not picked, or skipped. The + /// capability and [StopVerificationPage] both survive because the form is + /// what a chain-of-custody client would need on the day one asks for it, and + /// this is the switch that would turn it back on for that tenant alone. + final bool needsVerification; + + /// A camera step on the final status change. + final bool needsProofPhoto; + + /// The rider decides stop by stop. False for a service run: he cannot + /// decline one subscriber's lunch, so the whole assignment is accepted at + /// once and the per-row Accept/Reject controls come off the card. + final bool acceptsPerStop; + + /// Collection is grouped by source (a kitchen), not done per customer. + /// + /// Also what makes Home group its stops under a heading per source and offer + /// one bulk collect per group: a rider standing at a counter with a crate is + /// doing one thing, not five. + final bool sourceIsKitchen; + + /// When a stop stops being Home's problem and becomes the work tab's. + /// + /// A parcel route hands off at **accept**: taking a booking is the decision, + /// and everything after it happens at the customer's door. + /// + /// A meal run hands off at **collect**, because accepting changes nothing + /// physical — the rider still has to go to a kitchen and be given the food. + /// Moving the card at accept would empty Home the moment he arrived for his + /// shift and leave him working a list of meals he is not carrying. So the rule + /// is: **Home holds an order until it is in his hands.** + final HandoffPoint handoffAt; + + /// One name for every stop's work type, or null to let each stop name its own + /// (`PICKUP` / `DELIVERY` / `P&D`). + /// + /// A meal run is one job repeated — collect a crate, drop the boxes — and + /// labelling half of a rider's day PICKUP and half DELIVERY invites him to + /// look for a difference in handling that does not exist. The stop's real + /// kind is still tracked underneath, because the collection gate in P3 needs + /// it; this is only what the chip says. + final String? workTypeLabel; + + /// Loading is done crate-at-a-counter, so it takes one photo for the whole + /// load rather than one per order. + /// + /// A rider standing at a kitchen hatch with fifteen boxes cannot photograph + /// each one, and the thing worth photographing is the *crate* — what he was + /// handed, in one frame, at one time. False on a parcel route, where proof is + /// per consignment because each one has its own chain of custody. + final bool bulkLoadProof; + + /// The rider asks the customer where this shipment is coming from and going + /// to, and the answers are written back to the booking. + /// + /// ── Why this is a rider's job at all ── + /// + /// A logistics booking often arrives half-addressed: the customer raised it + /// from a map pin and described the destination as a phone number and a + /// landmark. The rider is the first person standing in front of the sender + /// who can ask. Those answers decide the consignment's routing and its + /// pricing zone, so they have to be captured before the order is initiated. + /// + /// False on a milk run, where both ends were fixed when the customer + /// subscribed and there is nothing for the rider to establish. + final bool capturesShipmentAddresses; + + /// The fare is computed at the door from the real shipment, and shown to the + /// customer before he pays. + /// + /// Follows [capturesShipmentAddresses] — a price needs a from, a to and a + /// weight, and a line that does not collect the first two cannot quote. + final bool pricesShipment; + + /// Collection ends by converting the booking into a live shipment with a + /// tracking number — the moment the order really exists. + /// + /// On a milk run collection is the *middle* of the day, not the end of a + /// transaction, so there is nothing to initiate. + final bool initiatesShipment; + + /// The rider carries the load on to the customers himself. + /// + /// True for a milk run: collection is followed by a round of drops he makes + /// personally. False for logistics, whose collected shipment goes to the hub + /// and is delivered by somebody else on another day — which is why that line + /// ends at a warehouse and this one ends at the last door. + final bool deliversToCustomer; + + /// Delivery does not begin until every assigned pickup is in the rider's + /// hands, and it begins on an explicit press. + /// + /// ── Why the whole load, and why a press ── + /// + /// A milk-run rider works two or three sources before he starts driving to + /// customers. Letting each collected order drift into the delivery list on + /// its own gives him a half-built round: he sets off after kitchen one, and + /// kitchen two's five orders appear behind him. So the round is held until + /// the load is complete and then released in one gesture, which is also the + /// moment the shipments legitimately become "out for delivery" server-side. + final bool startsDeliveryAfterFullLoad; + + const ServiceProfile({ + required this.line, + required this.label, + required this.jobNoun, + required this.jobNounPlural, + required this.completionVerb, + required this.collectsCash, + required this.needsVerification, + required this.needsProofPhoto, + required this.acceptsPerStop, + required this.sourceIsKitchen, + required this.handoffAt, + this.bulkLoadProof = false, + this.capturesShipmentAddresses = false, + this.pricesShipment = false, + this.initiatesShipment = false, + this.deliversToCustomer = false, + this.startsDeliveryAfterFullLoad = false, + this.workTypeLabel, + }); + + /// First-mile parcel logistics — the app as it has always been. Also the + /// fallback for an unrecognised tenant, see [TenantController.load]. + static const ServiceProfile parcel = ServiceProfile( + line: ServiceLine.parcel, + label: 'Doormile', + jobNoun: 'booking', + jobNounPlural: 'bookings', + completionVerb: 'Picked up', + collectsCash: true, + needsVerification: true, + needsProofPhoto: true, + acceptsPerStop: true, + sourceIsKitchen: false, + handoffAt: HandoffPoint.accepted, + // The full shipment desk at the customer's door: where is it from, where + // is it going, what does it weigh, what does that cost, and take the money + // — then turn it into a real consignment bound for the hub. + capturesShipmentAddresses: true, + pricesShipment: true, + initiatesShipment: true, + // The collected shipment goes to the hub; somebody else delivers it. + deliversToCustomer: false, + // Each stop names its own kind: a parcel route genuinely mixes them. + workTypeLabel: null, + ); + + /// **Milk Man delivery.** Load a crate at one or more sources, then a round + /// of prepaid drops. No money, no verification form, no per-stop decision. + static const ServiceProfile milkMan = ServiceProfile( + line: ServiceLine.milkMan, + label: 'Doormile Delivery', + jobNoun: 'delivery', + jobNounPlural: 'deliveries', + completionVerb: 'Delivered', + collectsCash: false, + needsVerification: false, + needsProofPhoto: false, + acceptsPerStop: false, + sourceIsKitchen: true, + handoffAt: HandoffPoint.collected, + bulkLoadProof: true, + // None of the shipment desk: these orders were booked and paid for before + // the rider's shift started. + capturesShipmentAddresses: false, + pricesShipment: false, + initiatesShipment: false, + // He collects the load and delivers it himself, once it is all aboard. + deliversToCustomer: true, + startsDeliveryAfterFullLoad: true, + // One word, deliberately. The chip is a fixed slot beside the state tag and + // a two-word label is the one that gives way — "MILK RU…" on every card in + // the list. `test/service_card_test.dart` holds this. + // ── Never the internal name of the line ── + // + // This was `MILK`, and it was printed on the rider's cards and on his + // Activity record as `Type: MILK`. That is the app's own vocabulary for a + // *service profile* — an implementation detail he did not choose, cannot + // change and gains nothing from. What he is actually doing at every stop + // on this line is a delivery, so that is what the mark says. + // + // The profile itself stays exactly as it was: it is what separates one + // rider's records from another's and decides which capabilities exist. + // That separation is a data rule, and it belongs in the data, not on a + // badge. + workTypeLabel: 'DELIVERY', + ); + + /// The old name for [milkMan], from when the line had one food client. + /// + /// Kept so the existing call sites and tests that name it this way keep + /// compiling; there is one profile, under two names. + static const ServiceProfile meals = milkMan; + + /// Last-mile delivery — the ported Xpress-rider flow. + /// + /// The capability values here describe the work honestly (a delivery rider + /// does collect cash on COD, does photograph a doorstep, does take jobs one at + /// a time), but almost nothing reads them: this line renders its own screens, + /// so it does not go through the capability branches the parcel screens use. + /// They matter only if a delivery rider is ever routed into a shared screen. + static const ServiceProfile delivery = ServiceProfile( + line: ServiceLine.delivery, + label: 'Doormile', + jobNoun: 'delivery', + jobNounPlural: 'deliveries', + completionVerb: 'Delivered', + collectsCash: true, + needsVerification: false, + needsProofPhoto: true, + acceptsPerStop: true, + sourceIsKitchen: false, + handoffAt: HandoffPoint.accepted, + workTypeLabel: 'DELIVERY', + ); + + /// True when an accepted stop stays on Home until the rider is carrying it. + bool get handsOffAtCollection => handoffAt == HandoffPoint.collected; + + /// True when every stop wears the same work-type mark — see [workTypeLabel]. + bool get usesSingleWorkType => workTypeLabel != null; + + /// True on the Milk Man line — collect at a source, then deliver. + bool get isMilkMan => line == ServiceLine.milkMan; + + /// The old name for [isMilkMan]. + bool get isMeals => isMilkMan; + + /// ── Whether the backend can serve this line's work at all ── + /// + /// True on every line, and that is a correction, not a shortcut. This read + /// `!sourceIsKitchen`, on the reasoning that the `/miler/*` contract serves + /// bookings and consignments while kitchens, crates and subscribers are not + /// concepts it has — so a milk-man rider could have no endpoint to ask. + /// + /// **The hub does not dispatch that way.** The console's dispatch board reads + /// the milk round from `GET /admin/bookings` and puts a rider on a stop with + /// `POST /hub/bookings/:id/auto-assign`; the rider-facing read of those very + /// rows is `GET /miler/bookings`. A milk round *is* bookings today. The + /// kitchen vocabulary describes how this app **renders** the work — load at a + /// source, hand off at collection, one MILK chip per card — not where the + /// work comes from. + /// + /// While the two were conflated, a rider on the milk-man line could not be + /// given work at all: [WorkRepository] returns `LoadUnavailable` on a false + /// answer here and never issues the request, so Home, Bookings and Activity + /// were all blank however many bookings the hub had assigned him. That is how + /// it was found — Rajan A (userid 38, tenant 13) was assigned a booking in the + /// console and the app showed him nothing, with no failure anywhere to + /// explain it. + /// + /// The flag stays because the state it feeds is worth keeping: a line the + /// backend genuinely cannot answer for should render "your hub has not + /// enabled this yet" rather than an empty list that reads as a quiet day. It + /// is now something a future line sets deliberately, not something a rider + /// inherits from how his round is shaped. + /// + /// It must never be satisfied by [MealRunMock]. That fixture is an opt-in + /// development tool behind `--dart-define=MOCK_BACKEND=true`; wiring it in + /// here would put fabricated business data on a production path. + bool get hasBookingsEndpoint => true; + + /// True when the rider's day ends back at the depot. + /// + /// A logistics rider collects shipments at customers' doors and carries them + /// to the hub, so the last leg of his route is a building. A **milk-man** + /// round is the other way up: he loads at the kitchen at the start and his + /// last address is a customer's door, so there is nothing to return and + /// nowhere to return it to — his day ends when the round does. + /// + /// Derived from [deliversToCustomer] rather than stored, because they are the + /// same fact: a line that hands goods to the customer has already delivered + /// its load by the time it finishes. + bool get endsAtHub => !deliversToCustomer; + + /// True on the Logistics line — the shipment desk at the customer's door. + bool get isLogistics => line == ServiceLine.parcel; + + /// The old name for [isLogistics]. + bool get isParcel => isLogistics; + + /// True when this rider gets the ported Xpress-rider screens rather than the + /// parcel ones. Read by `helpers/rider_shell.dart` and by nothing else — see + /// the note on [ServiceLine.delivery]. + bool get isDelivery => line == ServiceLine.delivery; + + // ── Reading the active profile ── + // + // A plain static, deliberately, and not a GetX lookup. + // + // [stopCollectionAmount] is a pure map helper called from model code and from + // tests with no widget tree at all. Routing it through `Get.put` made it + // initialise a real `WidgetsFlutterBinding` as a side effect of asking "is + // any money owed here?" — which is enough to stop an unrelated widget test in + // another file from starting at all. Data code must not be able to boot the + // framework. + // + // [TenantController] still owns loading and still publishes an `Rx` for + // screens that want to rebuild; this is the synchronous read everything else + // uses. + + static ServiceProfile _active = parcel; + + /// The signed-in rider's profile. + /// + /// Parcel until [TenantController.load] says otherwise — see that method for + /// why the fallback runs in that direction. + static ServiceProfile get active => _active; + + /// Sets the active profile. Called by [TenantController.load]; also the seam + /// tests use to put the app on one line or the other. + static void setActive(ServiceProfile profile) => _active = profile; +} + +/// Resolves the rider's line of work from what login persisted. +/// +/// Order: **the rider's own tenant** (name, then id) → build override → +/// logistics. +/// +/// ── Why the server now wins over the build flag ── +/// +/// This used to read the `TENANT` dart-define first, because `verify-pin` +/// returned no tenant at all and the flag was the only signal there was. That +/// is no longer true: the login response carries `tenantid` and `tenantname`, +/// so the rostered answer exists and the app can simply ask. +/// +/// Which way round these two go is a real decision, not a detail. "Which work +/// am I doing today?" must be answered by the hub that rostered the rider, not +/// by whoever compiled the APK — one build serves every rider, and a flag that +/// outranks the account means one wrong build puts every rider on the wrong +/// flow. So the flag is now what it should always have been: a fallback for +/// builds signed in against a backend that cannot answer, and a way to demo a +/// line without a matching account. +/// +/// A free function rather than a controller method so it can be tested without +/// a GetX container — see the note on [ServiceProfile.active]. +Future resolveServiceProfile() async { + final prefs = await SharedPreferences.getInstance(); + + // ── What the rider's account says ── + // + // Name first: it is the field a human can check against the admin console, + // and it means a new tenant does not need an app release. An unrecognised + // name is not an answer, so it falls through to the id. + final name = (prefs.getString(TenantController.kTenantName) ?? '') + .trim() + .toLowerCase(); + if (name.isNotEmpty) { + final byName = TenantController.profileForName(name); + if (byName != null) return byName; + } + + final id = prefs.getInt(TenantController.kTenantId) ?? 0; + if (id != 0) { + final byId = TenantController.profileForId(id); + if (byId != null) return byId; + } + + // ── The tenant the session's own token claims ── + // + // Read after the persisted pair and before the build flag, and it exists for + // two cases the pair does not cover. + // + // The first is a backend whose `verify-pin` does not return the tenant in its + // body. It signs one into every token regardless, so the answer is in the + // app's hands either way — this is the same fact from the same source. + // + // The second is the rider who is **already signed in**. His prefs were + // written by an older build that stored tenant 0, and nothing would correct + // that until he happened to log out; reading his live session instead means + // the next launch resolves him properly with no action from him at all. + // + // Not a security decision — see [ApiConfig.tenantIdFromToken]. + final claimed = ApiConfig.tenantIdFromToken(prefs.getString('authtoken')); + if (claimed != 0) { + final byClaim = TenantController.profileForId(claimed); + if (byClaim != null) return byClaim; + } + + // ── The build's declared line, as a fallback ── + // + // Reached only when the account said nothing this app recognises. Unset + // means logistics, so an ordinary build is unchanged. + if (TenantController.buildOverride.isNotEmpty) { + final override = TenantController.buildOverride.trim().toLowerCase(); + final byOverride = TenantController.profileForName(override); + if (byOverride != null) return byOverride; + // The ported Xpress line is reachable ONLY from the build flag — never + // from a tenant name or id off the wire. It is a reference implementation + // that lives beside this app, not one of the two operations the hub runs, + // and a tenant that happened to be called "delivery" must not tip a live + // rider into a different application. See `helpers/rider_shell.dart`. + if (TenantController.deliveryTenantNames.contains(override)) { + return ServiceProfile.delivery; + } + } + + return ServiceProfile.parcel; +} + +/// Resolves the signed-in rider's line of work, once, and hands out its profile. +/// +/// Follows [DutyController]: a permanent GetX singleton with a `.to` accessor +/// and a `load()` that reads persisted prefs, so any screen can read the +/// profile synchronously without an await in `build`. +class TenantController extends GetxController { + /// Parcel until proven otherwise — see [load] for why that direction. + final Rx profile = ServiceProfile.parcel.obs; + + /// Persisted by the login response. See `auth_provider.dart`. + static const String kTenantId = 'tenantid'; + static const String kTenantName = 'tenantname'; + + /// Tenant ids whose riders work the **Milk Man** line. + /// + /// **This is the switch the hub actually operates.** A rider is put on the + /// milk-run flow by being given one of these tenant ids at login and nothing + /// else — no separate app, no separate build, no flag on his phone. + /// + /// **13** is the tenant the Milk Man riders are on — the account Rajan A + /// (userid 38) signs in with, confirmed from the `tenantid` claim on a live + /// login against `api.doormile.com`. + /// + /// ── Why an id is pinned here at all ── + /// + /// The name is the better switch and stays the first thing checked. But the + /// deployed backend does not return `tenantname` yet (the handler that does + /// is written and not released), so today the id is the only signal a real + /// rider carries. When the deploy lands, the name will match first and this + /// becomes a belt-and-braces second answer rather than the load-bearing one. + /// + /// **If tenant 13 is not the milk-run client**, this line is the whole fix: + /// remove the 13 and a rider on it goes back to logistics. + static const Set milkManTenantIds = {13}; + + /// The old name for [milkManTenantIds]. + static const Set mealTenantIds = milkManTenantIds; + + /// Name/code match, case-insensitive, against `tenantname` on the login. + /// + /// Cheaper than a release for every new tenant id, and it is the field a + /// human can actually verify against the admin console. + static const Set milkManTenantNames = { + 'milkman', + 'milk man', + 'milk-man', + 'milkrun', + 'milk run', + 'meals', + 'dailygrubs', + }; + + /// The old name for [milkManTenantNames]. + static const Set mealTenantNames = milkManTenantNames; + + /// Tenant ids whose riders work the **Logistics** line. + /// + /// Logistics is also the fallback, so this list exists to make a tenant + /// *explicitly* logistics rather than merely unrecognised — which matters + /// when a name would otherwise be ambiguous. + static const Set logisticsTenantIds = {}; + + /// The names that mean the Logistics line. + static const Set logisticsTenantNames = { + 'doormile', + 'logistics', + 'parcel', + }; + + /// The old name for [logisticsTenantNames]. + static const Set parcelTenantNames = logisticsTenantNames; + + /// The profile a tenant *name* means, or null when this app does not know + /// the name. Case-insensitive; callers pass an already-lowercased string. + /// + /// One lookup used by both the account path and the build override, so the + /// two can never disagree about what a name means. + static ServiceProfile? profileForName(String name) { + if (milkManTenantNames.contains(name)) return ServiceProfile.milkMan; + if (logisticsTenantNames.contains(name)) return ServiceProfile.parcel; + return null; + } + + /// The profile a tenant *id* means, or null when this app does not know it. + static ServiceProfile? profileForId(int id) { + if (milkManTenantIds.contains(id)) return ServiceProfile.milkMan; + if (logisticsTenantIds.contains(id)) return ServiceProfile.parcel; + return null; + } + + /// Tenant ids that would mean the ported Xpress-rider screens under + /// `lib/xpress`. + /// + /// **Permanently empty, deliberately.** That subtree is a *reference* + /// implementation kept beside this app — it is not one of the two operations + /// the hub runs, and the Milk Man flow is now implemented natively in Miler + /// rather than by routing riders into it. + /// + /// Nothing off the wire may reach it: a tenant that happened to be named + /// "delivery" must not tip a live rider into a different application with a + /// different bottom bar and a different set of endpoints. The only way in is + /// the [buildOverride] flag, for looking at it. See `resolveServiceProfile`. + static const Set deliveryTenantIds = {}; + + /// Build-override names that open the ported Xpress screens. Never matched + /// against a tenant off the login — see [deliveryTenantIds]. + static const Set deliveryTenantNames = { + 'delivery', + 'xpress', + 'express', + 'doormile xpress', + 'doormilexpress', + }; + + /// Account partitions that mean the ported delivery line. Retained for the + /// build-override path only; nothing off the wire is matched against it. + static const Set deliveryConfigIds = {6}; + + /// Which line this build declares, **when the rider's account does not say**. + /// + /// flutter run --dart-define=TENANT=milkman + /// flutter run --dart-define=TENANT=logistics + /// flutter run --dart-define=TENANT=delivery # the Xpress reference + /// + /// ── This is a testing aid, not the production switch ── + /// + /// A production rider's line comes from his own tenant, which `verify-pin` + /// now returns. This flag is read only when the account carries a tenant this + /// app does not recognise, so it cannot override a rostered rider — see the + /// ordering note on [resolveServiceProfile]. + /// + /// Two flags, and they answer different questions: + /// + /// • `TENANT` picks the **profile**, i.e. which screens the rider gets. + /// • `TENANT_ID` is the **number sent to the API** — see [MilerApi.tenantId]. + /// + /// Unset means logistics, so an ordinary build is unchanged. + static const String buildOverride = String.fromEnvironment('TENANT'); + + static TenantController get to => Get.isRegistered() + ? Get.find() + : Get.put(TenantController(), permanent: true); + + /// Resolves the tenant and publishes it, both to [ServiceProfile.active] for + /// synchronous reads and to [profile] for screens that rebuild on it. + /// + /// The fallback direction is the point: an unconfigured or unrecognised + /// tenant lands on **logistics**. The worst case that way is a milk-run rider + /// seeing a payment prompt he can dismiss; the other direction takes the + /// cash-collection screen away from a live logistics rider at a door. + Future load() async { + final resolved = await resolveServiceProfile(); + ServiceProfile.setActive(resolved); + profile.value = resolved; + debugPrint('[TENANT] resolved ${resolved.label}'); + return resolved; + } +} diff --git a/lib/data/stop_area.dart b/lib/data/stop_area.dart new file mode 100644 index 0000000..71b7a69 --- /dev/null +++ b/lib/data/stop_area.dart @@ -0,0 +1,203 @@ +/// ───────────────────────────────────────────────────────────────────────── +/// WHERE A STOP IS, IN ONE WORD +/// +/// A rider scanning twenty stops is deciding between *areas* — is Joe on the +/// way to Priya, or the opposite side of the city. He is not reading twenty +/// street strings, and he certainly is not reading twenty street strings cut +/// off mid-word: `SEQTEST Gandhipur…` answers nothing that a blank line would +/// not have answered, and costs a line to do it. +/// +/// So the collapsed queue shows the locality and the full address lives where +/// the rare question is asked — the detail sheet, and the map at the door. +/// +/// ── Why this is one function and not two ── +/// +/// Home and Deliveries both needed it and both had their own. The rule is +/// subtle enough to get wrong in exactly the same way twice: the first version +/// skipped a component only when it was *entirely* digits, so +/// `12 SNS Colony, Peelamedu, Coimbatore 641004` returned `12 SNS Colony` — a +/// street with a door number on it, presented as an area. Two copies of that +/// bug is two places to find it. +/// ───────────────────────────────────────────────────────────────────────── +library; + +/// The locality component of a stop's address, or `''` when it has none. +/// +/// Prefers the drop, because on a delivery leg the area that matters is where +/// the bag is going. Falls back to the pickup for a stop that carries no drop, +/// and returns nothing rather than inventing a placeholder. +String areaOf(Map stop, {bool preferDrop = true}) { + final keys = preferDrop + ? const [ + 'dropaddress', + 'DropAddress', + 'deliveryaddress', + 'pickupaddress', + 'PickupAddress', + ] + : const [ + 'pickupaddress', + 'PickupAddress', + 'dropaddress', + 'DropAddress', + 'deliveryaddress', + ]; + + for (final key in keys) { + final raw = (stop[key] ?? '').toString().trim(); + if (raw.isEmpty) continue; + + final parts = raw + .split(',') + .map((p) => p.trim()) + .where((p) => p.isNotEmpty) + .toList(); + + // ── An area is a NAME, not a description ── + // + // Three versions of this rule, each corrected by a real address: + // + // 1. "Skip a part only when it is entirely digits" printed `12 SNS + // Colony` — a door number presented as an area. + // 2. "Skip a part containing any digit" fixed that, but then + // `12 Peelamedu, Coimbatore` lost `12 Peelamedu` wholesale and answered + // with the *city* — and every stop on a Coimbatore route is in + // Coimbatore, so the queue said nothing at all. Seen on a device. + // 3. Now the digits are stripped from *inside* the part instead of taking + // the part with them: `12 Peelamedu` yields the name `Peelamedu`. + // + // What separates a locality from a landmark is length in words — names + // are short (Gandhipuram, Peelamedu), landmarks and streets are phrases + // (`Coimbatore Gandhipuram Mofussil Bus Stand`, verified live). And the + // parts are scanned in order because an Indian address runs specific → + // general: the earliest name is the most local one, which is exactly what + // beats `Coimbatore` when both are one-word names. + // + // The word left when a number is removed is sometimes administrative + // noise, not a place — `Ward 54` is not the ward's name — so those + // residues are discarded by a small stop list. + // `Ward 54` is not a place called Ward, and `4 Cross` — Indian street + // numbering — is not a place called Cross. Both from live addresses. + const noise = { + 'ward', 'zone', 'no', 'door', 'flat', 'floor', 'plot', 'block', // + 'cross', 'main', 'street', 'road', + }; + String? nameOf(String part) { + final words = [ + for (final w in part.split(RegExp(r'\s+'))) + if (!RegExp(r'[0-9]').hasMatch(w)) w, + ]; + if (words.isEmpty) return null; + // `RS Puram RS Puram` — the geocoder stammer [compactAddress] already + // collapses — reaches here too, seen live as `Gandhipuram Gandhipuram` + // on the queue. A name whose two halves are the same name is one name. + if (words.length.isEven && words.length >= 2) { + final half = words.length ~/ 2; + final a = words.sublist(0, half).join(' '); + final b = words.sublist(half).join(' '); + if (a.toLowerCase() == b.toLowerCase()) { + words.removeRange(half, words.length); + } + } + final name = words.join(' '); + if (words.length == 1 && + (name.length < 3 || noise.contains(name.toLowerCase()))) { + return null; + } + return name; + } + + // ── The city rung is named, because position cannot find it ── + // + // "Drop the last part" was tried and is structurally undecidable: an + // address that ends at the city (`2 Saibaba Colony, Coimbatore`) and one + // that ends at the locality (`12, Cross Cut Road, Gandhipuram`) look + // identical from the end. Both are live addresses, and each broke the + // heuristic the other needed. + // + // So the rung is knowledge, like the noise words above: the region this + // tenant operates in, plus the state/country tails geocoders append. A + // city name distinguishes nothing on a route that is entirely inside it, + // so it ranks last — but it still stands when it is all the address has, + // because an honest rung beats a blank. + bool isCity(String n) => const { + 'coimbatore', 'coimbatore north', 'coimbatore south', // + 'tamil nadu', 'india', 'chennai', 'tiruppur', 'erode', 'salem', + 'madurai', 'pollachi', 'mettupalayam', + }.contains(n.toLowerCase()); + + // A two-word name ending in a thoroughfare word is a street, not a + // locality — `Mettupalayam Road` must lose to the `RS Puram` behind it, + // exactly as the longer landmark phrases already do. + bool isStreet(String n) => const { + 'road', 'street', 'salai', 'lane', 'veedhi', 'highway', // + }.contains(n.split(' ').last.toLowerCase()); + + final names = [ + for (final p in parts) + if (nameOf(p) case final n?) n, + ]; + final local = [ + for (final n in names) + if (!isCity(n)) n, + ]; + + // In rank order: the first one-word name — a locality is a name and + // names are short; then the first short non-street name (RS Puram, + // Saibaba Colony); then any phrase left; and only then the city. + for (final n in local) { + if (!n.contains(' ')) return n; + } + for (final n in local) { + if (n.split(' ').length <= 2 && !isStreet(n)) return n; + } + if (local.isNotEmpty) return local.first; + if (names.isNotEmpty) return names.first; + } + return ''; +} + +/// A full address, minus the boilerplate a geocoder appends. +/// +/// The record page prints the address in full — it is the one screen whose +/// job is the specifics — and on live data that full address arrived as +/// `RS Puram RS Puram, Ward 24, West Zone, Perur, Coimbatore South, +/// Coimbatore, Tamil Nadu, 641002, India`, wrapped to two lines and cut with +/// an ellipsis. The trimmed tail was the useful half; the kept head began by +/// stammering. +/// +/// This strips only what carries no information at a door in this region: +/// the state and country rungs, a part that is nothing but a pincode, an +/// immediate word-for-word repeat inside a part, and a part that repeats the +/// one before it. Everything that names a place stays, in order. +String compactAddress(String raw) { + final parts = raw + .split(',') + .map((p) => p.trim()) + .where((p) => p.isNotEmpty) + .toList(); + + const tail = {'tamil nadu', 'india'}; + + String dedupWords(String part) { + final w = part.split(RegExp(r'\s+')); + if (w.length.isEven && w.length >= 2) { + final half = w.length ~/ 2; + final a = w.sublist(0, half).join(' '); + final b = w.sublist(half).join(' '); + if (a.toLowerCase() == b.toLowerCase()) return a; + } + return part; + } + + final kept = []; + for (final p in parts) { + final cleaned = dedupWords(p); + final lower = cleaned.toLowerCase(); + if (tail.contains(lower)) continue; + if (RegExp(r'^[0-9]{6}$').hasMatch(cleaned)) continue; + if (kept.isNotEmpty && kept.last.toLowerCase() == lower) continue; + kept.add(cleaned); + } + return kept.join(', '); +} diff --git a/lib/data/stop_contact.dart b/lib/data/stop_contact.dart new file mode 100644 index 0000000..1b7b80b --- /dev/null +++ b/lib/data/stop_contact.dart @@ -0,0 +1,89 @@ +/// ───────────────────────────────────────────────────────────────────────── +/// WHO THE RIDER IS RINGING +/// +/// One number on a screen is not one fact. `Call` beside a kitchen's name and +/// `Call` beside a customer's name are different promises, and a rider at a +/// door who reaches the kitchen instead has lost the two minutes the control +/// existed to save him. +/// +/// ── What the payload actually carries ── +/// +/// Verified against production 21 Aug 2026: `GET /miler/bookings` returns +/// exactly **one** phone field, `customerphone`, which the adapter stores as +/// `pickupcontactno` — a key named after the leg it was first read on rather +/// than after whose number it is. There is no separate drop or receiver +/// contact anywhere in the contract. +/// +/// So on today's data there is only one number to dial and it belongs to the +/// booking's customer: the sender on a logistics collection, the subscriber on +/// a meal delivery. Dialling it is correct on both legs. What was **not** +/// correct is the app announcing it as "Call customer" while the rider is +/// standing at a kitchen counter. +/// +/// This file therefore does two things: it prefers a genuine drop contact the +/// moment the backend ships one — the precedence is written now so that day +/// needs no archaeology — and it names who is being called, per leg, so the +/// label can never drift from the number again. +/// ───────────────────────────────────────────────────────────────────────── +library; + +/// A number to dial, and who answers it. +class StopContact { + const StopContact(this.number, this.who); + + /// Empty when the stop carries no usable number. Callers omit the control + /// rather than showing one that cannot dial. + final String number; + + /// `the kitchen` / `the customer` — used to build the accessible label and + /// any visible caption, so both are produced from one decision. + final String who; + + bool get isEmpty => number.isEmpty; + bool get isNotEmpty => number.isNotEmpty; + + /// `Call the customer`. + String get action => 'Call $who'; + + /// Resolves the contact for the leg being worked. + /// + /// [delivery] is the leg, not the line: a milk run's collection at a kitchen + /// is a pickup leg even though the line delivers to customers. Ask + /// `MilkRun.navigatesToCustomer` for it rather than deriving it here — one + /// answer to "which leg is this", used by the map, the sheet and this. + static StopContact forLeg( + Map stop, { + required bool delivery, + }) { + String read(List keys) { + for (final k in keys) { + final v = (stop[k] ?? '').toString().trim(); + if (v.isNotEmpty && v != 'null' && v != '0') return v; + } + return ''; + } + + // Written for the contract that exists *and* the one that is coming: a + // drop contact wins on a delivery leg the moment one is present, and until + // then the single number the payload carries is used on both. + const dropKeys = [ + 'dropcontactno', + 'DropContactNo', + 'deliverycontactno', + 'receiverphone', + 'dropphone', + ]; + const pickupKeys = [ + 'pickupcontactno', + 'PickupContactNo', + 'contactno', + 'customerphone', + ]; + + final number = delivery + ? read([...dropKeys, ...pickupKeys]) + : read([...pickupKeys, ...dropKeys]); + + return StopContact(number, delivery ? 'the customer' : 'the pickup'); + } +} diff --git a/lib/data/work_domain.dart b/lib/data/work_domain.dart new file mode 100644 index 0000000..48b1f78 --- /dev/null +++ b/lib/data/work_domain.dart @@ -0,0 +1,168 @@ +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHICH SCREEN OWNS AN ORDER +/// +/// One rule, in one place, for the question both work screens have to answer +/// about every row of the shared day: **is this Home's or is it Deliveries'?** +/// +/// ── Why it is here and not on the screens ── +/// +/// It used to be answered twice. Home decided what to draw from +/// [stopStateOf] plus its own local stores; the Deliveries tab decided what to +/// list from a `where` clause inside its fetch, with a second `where` for the +/// statuses that clause was supposed to re-admit. Two expressions of one fact, +/// edited on different days, and they disagreed exactly where it hurts most: +/// **an accepted booking could be claimed by both.** It stayed on Home to be +/// collected, and it appeared on Deliveries as live work with a strip offering +/// to continue it — which opened a map, an I'VE ARRIVED and a confirmation +/// sheet for a collection the rider had not made yet. +/// +/// The fix is not a third filter. It is that the classification is one function +/// on the shared data layer, above both screens, so they cannot hold different +/// opinions about where an order belongs. +/// +/// ── The boundary ── +/// +/// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED +/// └──────────────── HOME owns all of this ──────────────┘ │ +/// ▼ +/// DELIVERIES owns from here on +/// +/// **Picked up is the boundary**, and only the backend can move it. Accepting +/// an assignment is a decision about work still to be done; it is not evidence +/// that anything has been collected. Nothing in this file infers a completed +/// pickup from an acceptance. +/// +/// ── The one knob ── +/// +/// Where the boundary sits is a property of the rider's line, declared once as +/// [ServiceProfile.handoffAt], and read here rather than re-derived: +/// +/// • **A kitchen line** ([HandoffPoint.collected]) hands over at collection. +/// The rider makes one trip to one counter for a stack of bags, so every +/// rung up to picked-up is Home's, in bulk, and Deliveries holds only what +/// is in his hands. +/// • **Logistics** ([HandoffPoint.accepted]) hands over at acceptance. There is +/// no counter and nothing to batch: he drives to one customer, raises the +/// shipment at the door and the collection *is* the job — so an accepted +/// booking is already the work tab's, which is where that flow lives. +/// +/// Same rule, one declared knob. Not a line-name check, and not a per-screen +/// conditional. See [ServiceProfile.handoffAt]. +/// ───────────────────────────────────────────────────────────────────────── +enum WorkDomain { + /// Home's. Pending, accepted, on the way, arrived — everything before the + /// backend has confirmed the load is aboard. + pickup, + + /// The Deliveries tab's. Past the hand-over point for this line. + delivery, + + /// Neither work screen's. Finished or withdrawn — Activity's record. + closed, +} + +/// The hand-over point between [WorkDomain.pickup] and [WorkDomain.delivery]. +abstract final class WorkBoundary { + /// True once the **backend** says the collection is done. + /// + /// Two sources, both authoritative, neither of them "the rider accepted it": + /// + /// • The status on the row — `Picked_Up` / `Converted_To_Consignment`, or + /// any of the delivery rungs past them. + /// • The collected record, written by Home only after the pickup-complete + /// call has come back successful. It exists because the queue is up to one + /// poll behind: without it a stop the rider has just handed over jumps back + /// to Home for a few seconds, which reads as the confirm having failed. + /// + /// It deliberately does **not** consult the accepted store. That set says a + /// decision was made, not that goods changed hands. + static bool pickupComplete( + Map stop, { + Set collectedIds = const {}, + }) { + final status = stopStatusOf(stop); + if (status.isPicked || status.isDeliveryLeg) return true; + return collectedIds.contains(MilkRun.idOf(stop)); + } + + /// True when this order is finished or withdrawn, whatever screen it was on. + /// + /// [StopStatus.picked] is terminal on a line that ends at the hub and is the + /// middle of the morning on one that does not — so it is asked of the line, + /// through the same knob everything else here reads. See + /// [ServiceProfile.endsAtHub]. + static bool isClosed(Map stop) { + final status = stopStatusOf(stop); + if (status == StopStatus.delivered || status.isCancelled) return true; + return status.isPicked && ServiceProfile.active.endsAtHub; + } + + /// Which screen owns this order. + /// + /// [rejectedIds] and a server-side rejection do **not** close an order here: + /// a declined stop stays in the pickup domain because Home is where the rider + /// can change his mind about it. What it must never be is delivery work. + static WorkDomain domainOf( + Map stop, { + Set collectedIds = const {}, + Set acceptedIds = const {}, + }) { + if (isClosed(stop)) return WorkDomain.closed; + + final handedOver = switch (ServiceProfile.active.handoffAt) { + HandoffPoint.collected => pickupComplete( + stop, + collectedIds: collectedIds, + ), + // On logistics the work tab takes it at acceptance — from either source, + // because the local store leads the queue by a poll. + HandoffPoint.accepted => + acceptedIds.contains(MilkRun.idOf(stop)) || + stopStatusOf(stop) == StopStatus.accepted || + stopStatusOf(stop).isActive || + stopStatusOf(stop) == StopStatus.arrived, + }; + + return handedOver ? WorkDomain.delivery : WorkDomain.pickup; + } + + /// Everything the Deliveries tab is allowed to hold, out of the shared day. + /// + /// The tab's list, the count on Home's pill and anything else that asks "how + /// much is waiting on the other tab?" all read this, so the two screens + /// cannot report different numbers for the same day. + static List> deliveryQueue( + Iterable> day, { + Set collectedIds = const {}, + Set acceptedIds = const {}, + }) => [ + for (final stop in day) + if (domainOf( + stop, + collectedIds: collectedIds, + acceptedIds: acceptedIds, + ) == + WorkDomain.delivery) + stop, + ]; + + /// Everything Home is still responsible for, out of the shared day. + static List> pickupQueue( + Iterable> day, { + Set collectedIds = const {}, + Set acceptedIds = const {}, + }) => [ + for (final stop in day) + if (domainOf( + stop, + collectedIds: collectedIds, + acceptedIds: acceptedIds, + ) == + WorkDomain.pickup) + stop, + ]; +} diff --git a/lib/data/work_repository.dart b/lib/data/work_repository.dart new file mode 100644 index 0000000..945a482 --- /dev/null +++ b/lib/data/work_repository.dart @@ -0,0 +1,321 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/route_order.dart'; +import 'package:miler/data/order_events.dart'; +import 'package:miler/data/assignment_lookup.dart'; +import 'package:miler/data/load_state.dart'; +import 'package:miler/data/meal_run_mock.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE DAY'S WORK, FETCHED ONCE +/// +/// Home, Deliveries and Activity all answer questions about the same set of +/// bookings, and each of them fetched it independently: three timers, three +/// copies of the list, three ideas of what had been accepted. Switching tabs +/// re-fetched. A rebuild re-fetched. Accepting a stop on Home updated Home's +/// copy and left the other two showing yesterday's answer until their own poll +/// came round. +/// +/// This is the one copy. The screens read [state] and listen to [changes]; none +/// of them fetches. +/// +/// ── Three things it does that a plain `Future` does not ── +/// +/// * **Collapses concurrent callers.** Three screens mounting in the same frame +/// produce one request, and all three get its result. The second and third +/// callers await the *same* future rather than starting their own. +/// * **Refuses stale writes.** Every load carries a sequence number, and a +/// response whose sequence is behind the newest one is dropped. A slow +/// refresh that lands after a fast one can no longer overwrite it — the +/// classic "pull to refresh, then the old response arrives and the list goes +/// backwards" bug. +/// * **Distinguishes the three empties.** Nothing today, could not ask, and no +/// endpoint for this line are different answers — see [LoadState]. +/// +/// ── What it deliberately does not do ── +/// +/// No polling of its own, no retry loop, no cache written to disk. The screens +/// own when to ask; this owns making sure asking twice costs one request. +/// ───────────────────────────────────────────────────────────────────────── +class WorkRepository { + WorkRepository._(); + + /// The app's instance. A plain static rather than a DI registration, matching + /// how [ServiceProfile] and the stores in this layer are reached — data code + /// here must not need a widget tree to exist. + static final WorkRepository instance = WorkRepository._(); + + final _controller = + StreamController>>>.broadcast(); + + LoadState>> _state = + const LoadLoading>>(); + + /// The current answer. Safe to read during `build`. + LoadState>> get state => _state; + + /// Every change, for screens that want to rebuild without polling. + Stream>>> get changes => + _controller.stream; + + /// The request in flight, if any. A second caller awaits this rather than + /// starting a second request. + Future>>>? _inFlight; + + /// Monotonic, so a late response can be recognised as late. + int _sequence = 0; + + /// How long a result stays fresh enough to hand to a new caller without + /// asking again. Covers the case this exists for: three screens mounting + /// within a frame of each other, and a tab switch a second later. + static const Duration freshFor = Duration(seconds: 10); + DateTime? _loadedAt; + + bool get _isFresh { + final at = _loadedAt; + return at != null && DateTime.now().difference(at) < freshFor; + } + + /// Loads the day, or hands back what is already loading or already fresh. + /// + /// [force] skips the freshness check — pull-to-refresh — but still collapses + /// into an in-flight request rather than racing it. + Future>>> load({bool force = false}) { + // ── Collapse, unless the rider asked ── + // + // An *incidental* load — a screen mounting, a tab switch, a rebuild — + // joins whatever is already running. That is the whole reason this exists, + // and it is what stops three screens producing three requests. + // + // A **forced** load does not, because a pull-to-refresh that quietly hands + // back the result of a request started before the rider's gesture is not a + // refresh. It starts its own, and the sequence guard in [_publish] drops + // whichever response lands out of order. + final existing = _inFlight; + if (existing != null && !force) return existing; + + if (!force && _isFresh && _state is LoadData) { + return Future.value(_state); + } + + final future = _fetch(); + _inFlight = future; + return future.whenComplete(() { + if (identical(_inFlight, future)) _inFlight = null; + }); + } + + /// Marks the current answer stale and re-reads it. + /// + /// Called after every mutation the server has confirmed, so the screens take + /// their new state from the API rather than from the button that was pressed. + Future>>> invalidate() { + _loadedAt = null; + return load(force: true); + } + + Future>>> _fetch() async { + final seq = ++_sequence; + + // Keep whatever is on screen visible while the refresh runs. A pull to + // refresh must not blank the list it is refreshing. + final current = _state; + _publish( + current is LoadData>> + ? LoadData(current.value, refreshing: true) + : const LoadLoading>>(), + seq, + ); + + // The opt-in development fixture, checked first and named as such. See + // [MealRunMock]; it is unreachable unless somebody passed MOCK_BACKEND. + if (MealRunMock.active) { + final day = MealRunMock.stops().cast>(); + debugPrint('[WORK] OPT-IN FIXTURE, not the API: ${day.length} stops'); + return _publish( + day.isEmpty + ? const LoadEmpty>>() + : LoadData(day), + seq, + ); + } + + // No fixture and no endpoint. Distinct from an empty day, because neither + // waiting nor retrying will change it. + if (!ServiceProfile.active.hasBookingsEndpoint) { + return _publish( + LoadUnavailable>>( + '${ServiceProfile.active.label} work is not served by this backend ' + 'yet.', + ), + seq, + ); + } + + final res = await MilerApi.bookings(); + + if (!res.ok) { + return _publish( + LoadFailure>>( + _classify(res), + message: res.message, + ), + seq, + ); + } + + final mapped = ApiConfig.pickupsFromBookings(res.list); + + // ── The hub's solved order, stamped on here and nowhere else ── + // + // `step` now ships on the booking row itself (verified live 21 Aug 2026), + // so the common path is that this finds nothing to do — which is the + // intended end state. It stays because it is the delivery leg's safety + // net: the assignment row is the field's original home, and a booking that + // arrives without one but has a live assignment carrying it must not lose + // the hub's order. **A `step` already on the payload always wins.** + // + // Merged at the repository rather than on a screen, because all three + // screens order by it and a stop that is third on Home must not be second + // on Deliveries. + // + // Best-effort, and deliberately so: the flow contract says an optimizer + // outage leaves work *assigned but unordered*, never undone. A failure here + // leaves every stop as it arrived, and [RouteOrder] then falls back — and + // says that it has, rather than passing its own guess off as the route. + await _stampSequence(mapped); + if (mapped.isEmpty && res.list.isNotEmpty) { + // Rows arrived and none survived translation — a field-name mismatch, + // not an empty day. Said out loud rather than rendered as "no work". + ApiConfig.logGap( + 'pickupFromBooking', + '${res.list.length} bookings returned but none mapped — check the ' + 'field names against a real payload.', + ); + return _publish( + const LoadFailure>>( + LoadFailureKind.server, + message: 'The hub sent work this app could not read.', + ), + seq, + ); + } + + // ── When the work reached this rider ── + // + // The contract has no assignment timestamp: `GET /miler/assignments` + // returns the rows and no clock on them, and the booking's own `updatedat` + // moves every time anything touches it — the console warns against reading + // it as an assignment time for exactly that reason. + // + // What the app can say truthfully is when a booking *first appeared in this + // rider's queue*, which is the moment it became his. Stamped here because + // this is the one place every screen's day comes through, and stamped once: + // [stampOrderEvent] never overwrites, so a poll a second later cannot move + // it. Read only by the Activity timeline; nothing decides anything on it. + // + // **Only work still in front of him.** Stamping every row the fetch carries + // put an `Assigned 3:34 PM` on a stop that had been delivered at 11:57 that + // morning: the ledger did not exist when the work arrived, so the first + // sighting was the app being opened in the afternoon. A clock that lands + // after the completion it is supposed to precede is worse than no clock — + // it is the timeline contradicting itself in the rider's face. Anything the + // backend already reports as past his hands is skipped, and stays skipped + // forever because [stampOrderEvent] never overwrites. + unawaited( + stampOrderEvents( + mapped + .where((s) { + final st = stopStatusOf(s); + return !st.isWorkComplete && + !st.isCancelled && + !st.isRejected && + !st.isSkipped && + !st.isPicked && + !st.isDeliveryLeg; + }) + .map((s) => (s['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty), + OrderEvent.assigned, + ), + ); + + _loadedAt = DateTime.now(); + return _publish( + mapped.isEmpty + ? const LoadEmpty>>() + : LoadData(mapped), + seq, + ); + } + + /// Writes the assignment sequence onto the day's stops, in place. + Future _stampSequence(List> day) async { + if (day.isEmpty) return; + try { + final steps = await AssignmentLookup.steps(); + if (steps.isEmpty) return; + var stamped = 0; + for (final stop in day) { + // Read through [RouteOrder] so "already sequenced" means the same + // thing here as it does at every screen that orders by it. + if (RouteOrder.sequenceOf(stop) > 0) continue; + final key = (stop['bookingid'] ?? stop['orderheaderid'] ?? '') + .toString() + .trim(); + final step = steps[key]; + if (step == null) continue; + stop['step'] = step; + stamped++; + } + debugPrint('[WORK] sequenced $stamped/${day.length} stops from the hub'); + final unsequenced = day + .where((s) => RouteOrder.sequenceOf(s) == 0) + .length; + RouteOrder.logUnsequenced('work-repository', unsequenced); + } catch (e) { + // Never fails the day's work: an unordered route is workable, a missing + // one is not. + debugPrint('[WORK] could not read the assignment sequence: $e'); + } + } + + /// Turns a refused call into the thing the rider does about it. + static LoadFailureKind _classify(ApiResult res) => switch (res.status) { + 0 => LoadFailureKind.offline, + 401 || 403 => LoadFailureKind.unauthorized, + 429 => LoadFailureKind.rateLimited, + _ => LoadFailureKind.server, + }; + + /// Publishes [next] unless a newer load has already started. + LoadState>> _publish( + LoadState>> next, + int seq, + ) { + if (seq < _sequence) { + // A slower earlier request finishing after a newer one. Dropping it is + // the whole reason the sequence exists. + debugPrint('[WORK] dropped stale response #$seq (newest is $_sequence)'); + return _state; + } + _state = next; + if (!_controller.isClosed) _controller.add(next); + return next; + } + + /// Test seam. Returns the repository to the state a fresh launch has. + @visibleForTesting + void resetForTest() { + _state = const LoadLoading>>(); + _inFlight = null; + _sequence = 0; + _loadedAt = null; + } +} diff --git a/lib/data/work_scope.dart b/lib/data/work_scope.dart new file mode 100644 index 0000000..4fef863 --- /dev/null +++ b/lib/data/work_scope.dart @@ -0,0 +1,202 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHO A RECORD BELONGS TO +/// +/// Miler runs two operations off one app and one login — a milk-man round and +/// a logistics day — and it stores finished work, skipped work, carried bags +/// and released orders in SharedPreferences. Every one of those keys was +/// **global**: `completed_bookings`, `skipped_bookings`, +/// `collected_order_ids`, `out_for_delivery_order_ids`. One phone, one key, +/// whoever wrote last. +/// +/// That is three leaks in one: +/// +/// • **Rider → rider.** Log out, log in as somebody else, and yesterday's +/// completed stops are sitting in the new rider's Activity. +/// • **Line → line.** A tenant switch moves the app from a round to a +/// logistics day; the milk-run's delivered lunches stayed behind in the +/// logistics history. +/// • **Tenant → tenant.** Same shape, one level up. +/// +/// ── Why identity and not a label ── +/// +/// The tempting fix is to filter Activity on something visible — a kitchen +/// name, the word "Milk", the tab's title. All of those are *display strings*: +/// they are localisable, they are chosen by hub staff, and two tenants can +/// legitimately use the same one. Ownership has to come from identity the +/// session actually proves: +/// +/// **rider** `userid` — from the login response, held in prefs +/// **tenant** `tenantid` — from the JWT claim, signed by the server +/// **line** [ServiceLine] — the operation the profile resolves to +/// +/// A [WorkScope] is those three together, and it is the only thing allowed to +/// decide which records a screen may see. +/// ───────────────────────────────────────────────────────────────────────── +@immutable +class WorkScope { + final int userId; + final int tenantId; + final ServiceLine line; + + const WorkScope({ + required this.userId, + required this.tenantId, + required this.line, + }); + + /// The scope of the session running right now. + /// + /// Reads the rider from prefs and the tenant from the token's claim — the + /// same two sources the rest of the app authenticates with — and takes the + /// line from the resolved profile. Never throws: an unreadable session + /// yields the [anonymous] scope, whose records are visible to nobody but + /// itself. + static Future current() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.get('userid'); + final userId = raw is int + ? raw + : int.tryParse(raw?.toString() ?? '') ?? 0; + final tenantId = await ApiConfig.storedTenantId(); + return WorkScope( + userId: userId, + tenantId: tenantId, + line: ServiceProfile.active.line, + ); + } catch (e) { + debugPrint('[SCOPE] could not resolve the session scope: $e'); + return WorkScope( + userId: 0, + tenantId: 0, + line: ServiceProfile.active.line, + ); + } + } + + /// A signed-out or unreadable session. Deliberately still a real scope + /// rather than null: code paths that run before login write to their own + /// drawer instead of into the last rider's. + static WorkScope get anonymous => + WorkScope(userId: 0, tenantId: 0, line: ServiceProfile.active.line); + + /// The suffix that turns a store key into this scope's key. + /// + /// `completed_bookings` → `completed_bookings::u38.t13.milkMan` + /// + /// All three parts are in it because all three can change independently: a + /// rider can move tenant, a tenant can run either line, and one device can + /// see several riders. + String get key => 'u$userId.t$tenantId.${line.name}'; + + /// Scopes a legacy global key. + String scoped(String baseKey) => '$baseKey::$key'; + + /// Does [record] provably belong to this scope? + /// + /// Used on rows that were written before scoping existed, and as a + /// belt-and-braces check on rows read back from a scoped key. A row proves + /// ownership by carrying the identity itself — `mileruserid` / `userid` and + /// `tenantid` are what the API stamps on assignment and consignment rows. + /// + /// **Absence is not proof.** A row with no identity on it returns false: it + /// might be this rider's and it might be the last one's, and the only safe + /// reading of "might" is no. + bool owns(Map record) { + int intOf(List keys) { + for (final k in keys) { + final v = record[k]; + if (v == null) continue; + final n = v is int ? v : int.tryParse(v.toString()); + if (n != null && n != 0) return n; + } + return 0; + } + + final rowUser = intOf(const [ + 'mileruserid', + 'MilerUserId', + 'assignedmileruserid', + 'userid', + 'scopeuserid', + ]); + final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']); + final rowLine = (record['scopeline'] ?? '').toString(); + + if (rowUser == 0 && rowTenant == 0 && rowLine.isEmpty) return false; + if (rowUser != 0 && rowUser != userId) return false; + if (rowTenant != 0 && rowTenant != tenantId) return false; + if (rowLine.isNotEmpty && rowLine != line.name) return false; + return true; + } + + /// Does [record] prove it belongs to **another** scope? + /// + /// The mirror of [owns], and deliberately not its negation — they answer + /// different questions and treat silence differently: + /// + /// [owns] "prove this is mine" — no identity ⇒ **false** (drop). + /// Used on legacy rows, where attributing the unattributable + /// is the leak itself. + /// [excludes] "prove this is someone + /// else's" — no identity ⇒ **false** (keep). + /// Used on rows the API just returned for the authenticated + /// session, which are already the rider's by construction and + /// mostly carry no identity of their own. Dropping those on + /// silence would empty the tab. + bool excludes(Map record) { + int intOf(List keys) { + for (final k in keys) { + final v = record[k]; + if (v == null) continue; + final n = v is int ? v : int.tryParse(v.toString()); + if (n != null && n != 0) return n; + } + return 0; + } + + final rowUser = intOf(const [ + 'mileruserid', + 'MilerUserId', + 'assignedmileruserid', + 'scopeuserid', + ]); + final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']); + final rowLine = (record['scopeline'] ?? '').toString(); + + if (rowUser != 0 && userId != 0 && rowUser != userId) return true; + if (rowTenant != 0 && tenantId != 0 && rowTenant != tenantId) return true; + if (rowLine.isNotEmpty && rowLine != line.name) return true; + return false; + } + + /// Stamps [record] so a later read can prove ownership without a session. + /// + /// Written at the moment a record is stored, which is the one moment the + /// scope is known for certain. + Map stamp(Map record) => { + ...record, + 'scopeuserid': userId, + 'scopetenantid': tenantId, + 'scopeline': line.name, + }; + + @override + bool operator ==(Object other) => + other is WorkScope && + other.userId == userId && + other.tenantId == tenantId && + other.line == line; + + @override + int get hashCode => Object.hash(userId, tenantId, line); + + @override + String toString() => 'WorkScope($key)'; +} diff --git a/lib/helpers/app_bootstrap.dart b/lib/helpers/app_bootstrap.dart index 63220fd..734ca0e 100644 --- a/lib/helpers/app_bootstrap.dart +++ b/lib/helpers/app_bootstrap.dart @@ -8,7 +8,7 @@ import 'package:miler/controllers/auth.dart'; import 'package:miler/views/introscreens/introscreen.dart'; import 'package:miler/views/onboardscreens/Mpin.dart'; import 'package:miler/views/onboardscreens/Sign_in.dart'; -import 'package:miler/widget/Bottom_page.dart'; +import 'package:miler/helpers/rider_shell.dart'; /// Decides which screen the app opens on, and nothing else. /// @@ -109,7 +109,11 @@ class _AppBootstrapState extends State { // pass a slider that put him back on the clock. Duty is a switch in Home's // app bar, on the screen where the work is. if (!isLoggedOut && savedUserId != null && savedUserId > 0) { - Get.offAll(() => const BottomPage()); + // Which of the two dashboards this rider works in — parcel or the ported + // delivery flow — decided by his tenant. `main()` awaits + // `TenantController.load()` before the first frame, so the answer is + // already on hand here. See [riderShell]. + Get.offAll(() => riderShell()); } else { Get.offAll(() => hasSeenIntro ? const SignIn() : Introscreen()); } diff --git a/lib/helpers/rider_shell.dart b/lib/helpers/rider_shell.dart new file mode 100644 index 0000000..a5f7966 --- /dev/null +++ b/lib/helpers/rider_shell.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/widget/Bottom_page.dart' as parcel; +import 'package:miler/xpress/delivery_bootstrap.dart'; +import 'package:miler/xpress/widget/Bottom_page.dart' as delivery; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHICH APP THIS RIDER GETS +/// +/// One APK, two applications. A rider signs in and lands in whichever of them +/// his tenant says he works for: +/// +/// • **parcel / meals** → the Miler dashboard: floating frosted tab bar, Home · +/// Bookings · Activity · Account, the pickup flow with its stop verification +/// and cash collection. +/// +/// • **delivery** → the ported Xpress-rider dashboard under `lib/xpress`: +/// classic bottom bar, HOME · DELIVERIES · SUMMARY · PROFILE, the last-mile +/// delivery flow, in Doormile red. +/// +/// ── Why the switch is here and not inside the shell ── +/// +/// The obvious alternative is one `BottomPage` that picks its own tabs. It does +/// not work: the two shells disagree about how many tabs there are, what a tab +/// is called, how the bar is drawn, and — the part that actually decides it — +/// which page widgets exist. The delivery pages are a verbatim port that reads +/// its own controllers, its own models and its own colour class. Fusing the two +/// shells would mean editing the ported flow, which is the one thing the port +/// was meant to avoid. +/// +/// So the shells stay whole and separate, and exactly one function chooses +/// between them. +/// +/// ── Why it is a function and not a route ── +/// +/// Every caller already says `Get.offAll(() => const BottomPage())`. Making this +/// a widget-returning function means those sites become +/// `Get.offAll(() => riderShell())` and nothing else about routing changes — +/// no named routes, no redirect middleware, no second navigator. +/// +/// ── When it is safe to call ── +/// +/// After `TenantController.load()` has run, which `main()` awaits before the +/// first frame. [ServiceProfile.active] is a plain synchronous read, so this +/// needs no context, no await and no rebuild. +/// +/// ── The fallback ── +/// +/// Anything that is not explicitly the delivery line gets the parcel shell, +/// matching the direction [resolveServiceProfile] already falls in. The worst +/// case that way is a delivery rider seeing the parcel dashboard, which is +/// wrong but navigable; the other direction would drop a live parcel rider into +/// an app with no pickup flow at all. +/// ───────────────────────────────────────────────────────────────────────── +Widget riderShell({int initialIndex = 0, Widget? overridePage}) { + if (ServiceProfile.active.isDelivery) { + // ── Why this is here and not only in `main()` ── + // + // `main()` boots the delivery line's controllers when the app *starts* on a + // delivery tenant. But a rider can also arrive here without a restart: sign + // out of a parcel account, sign in to a delivery one, and `Mpin` routes + // straight to this function inside a process whose container only ever held + // the parcel controllers. + // + // The delivery home page resolves `DeliveryController` in a field + // initialiser, so that path would throw while building rather than degrade. + // [registerDeliveryLine] is `isRegistered`-guarded, so calling it on every + // shell build costs four map lookups and closes the hole. + // + // Deliberately the **synchronous** half of the bootstrap, not the `async` + // one. An un-awaited async registration returns at its first `await`, the + // framework builds this shell in the gap, and the page throws on a + // controller that is about to exist — see the note on [registerDeliveryLine]. + // The IO half is `main()`'s to await; nothing on screen needs it to have + // finished, because the screens re-read that state anyway. + registerDeliveryLine(); + + return delivery.BottomPage( + initialIndex: initialIndex, + overridePage: overridePage, + ); + } + return parcel.BottomPage( + initialIndex: initialIndex, + overridePage: overridePage, + ); +} diff --git a/lib/main.dart b/lib/main.dart index af80d1a..224ae37 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:io'; import 'package:miler/helpers/http_overrides.dart'; import 'package:flutter/material.dart'; @@ -20,6 +21,9 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/background/backgroundservice.dart'; import 'package:miler/helpers/shift_end_alarm.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/xpress/delivery_bootstrap.dart'; import 'package:miler/views/helpers/constants/app_theme.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; @@ -73,6 +77,9 @@ Future recheckVersion() async { Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // One-time drain of the pre-scoping global stores. See [migrateLegacyStores] + // for why unattributable rows are dropped rather than adopted. + unawaited(migrateLegacyStores()); HttpOverrides.global = MyHttpOverrides(); try { @@ -83,10 +90,39 @@ Future main() async { // Firebase may not be configured for this platform (e.g. iOS simulator without GoogleService-Info.plist) } + // Which business this rider works for, resolved once from the login response + // — see [TenantController]. Before the first frame, because it decides + // whether Home draws a payment-carrying parcel route or a meal run — and, + // now, whether the rider gets the parcel dashboard at all or the ported + // Xpress-rider delivery one under `lib/xpress`. + // + // Moved above the controller registrations below, which it now selects. + final profile = await TenantController.to.load(); + + // ── One line's controllers, not both ── + // + // The delivery line has its own `RiderLogController`, `LogController` and + // `ProfileController` — same names, different classes, and GetX keys on the + // type. Registering both sets would put one rider on two duty-logging loops + // writing to two different backends. See [bootstrapDeliveryLine]. + if (profile.isDelivery) { + await bootstrapDeliveryLine(); + _applySystemChrome(); + runApp(const _RootApp()); + return; + } + // Loaded at boot, not on first visit to Account: the Home app bar draws the // rider's photo, so the controller has to be populated before the first // frame or the avatar flashes its letter fallback on every launch. await Get.put(ProfileController(), permanent: true).loadFromPrefs(); + + // Anything the retired demo layer left on this device. Deleting the mock + // generator from the source could not reach rows it had already written to + // SharedPreferences, and those kept surfacing on Bookings and Activity with + // nothing in the repo to explain them. See [purgeDemoRecords]. + await purgeDemoRecords(); + Get.put(RiderLogController(), permanent: true); Get.put(PickupController(), permanent: true); final logController = Get.put(LogController(), permanent: true); diff --git a/lib/providers/Riderlog/riderlog_provider.dart b/lib/providers/Riderlog/riderlog_provider.dart index 10e52b5..ea0e60c 100644 --- a/lib/providers/Riderlog/riderlog_provider.dart +++ b/lib/providers/Riderlog/riderlog_provider.dart @@ -62,11 +62,7 @@ Future?> _startDuty(Map data) async { return { 'status': true, 'code': 200, - 'details': { - 'logid': id, - 'login': res.map['loginat'], - 'onduty': 1, - }, + 'details': {'logid': id, 'login': res.map['loginat'], 'onduty': 1}, }; } // "Already on duty" is a 400 and is not a failure — it is the app and the @@ -133,7 +129,9 @@ Future?> _heartbeat(Map data) async { ), ); - return res.ok ? ApiConfig.okEnvelope() : {'status': false, 'code': res.status}; + return res.ok + ? ApiConfig.okEnvelope() + : {'status': false, 'code': res.status}; } /// True when the payload means "go off duty". diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index 7174ce7..dc3579a 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -1,401 +1,660 @@ -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert'; -import 'package:miler/Models/login/login.dart'; -import 'package:miler/data/api_config.dart'; -import 'package:miler/data/miler_api.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class AuthProvider { - Future login({ - required String contactNo, - required String deviceType, - required int configId, - required String deviceId, - required String fcmToken, - int? pin, - String? pinRaw, - }) async { - // The legacy `jupiter.doormile.app/.../rider/login` path that used to sit - // behind a flag here is gone with the rest of the old backend. - return _loginNew( - contactNo: contactNo, - pin: pin, - pinRaw: pinRaw, - fcmToken: fcmToken, - ); - } - - /// NEW API: POST /miler/verify-pin { phone, pin, device_token } - /// -> { success, token, data:{ userid, displayname, phone, hubid, - /// availabilitystatus, rating } } - /// - /// We store the bearer token, then return a synthetic http.Response whose body - /// is the LEGACY `{status, details:{...}}` shape so [loginParsed] persists the - /// same SharedPreferences keys it always has — no change to the auth UI flow. - /// - /// `device_token` is REQUIRED for the rider to receive push at all: the backend - /// stores it on the miler profile at verify-pin time and - /// AssignMilerToBooking pushes "New Pickup Assigned" to exactly that token. - /// Omitting it leaves the profile's token empty and silently makes the app - /// poll-only. - Future _loginNew({ - required String contactNo, - int? pin, - String? pinRaw, - String? fcmToken, - }) async { - final uri = Uri.parse(ApiConfig.url('/miler/verify-pin')); - // The backend bcrypt-compares the PIN as a STRING, so a leading zero is - // significant. Prefer the raw text the rider typed — round-tripping through - // int drops it ("0512" -> 512 -> "512") and fails a valid PIN. - final String? pinValue = (pinRaw != null && pinRaw.isNotEmpty) - ? pinRaw - : pin?.toString(); - final body = { - 'phone': contactNo, - if (pinValue != null) 'pin': pinValue, - // Riders live in partition 1001. It defaults server-side, so omitting it - // appeared to work — but a miler row created without it can never log in, - // and sending it explicitly is the only way the app and the console agree - // about which partition a rider belongs to. - 'configid': MilerApi.configId, - if (fcmToken != null && fcmToken.isNotEmpty) 'device_token': fcmToken, - }; - debugPrint('[AUTH][LOGIN][NEW] URL: $uri'); - debugPrint('[AUTH][LOGIN][NEW] Body: ${json.encode(body)}'); - - final res = await http.post( - uri, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: json.encode(body), - ); - debugPrint('[AUTH][LOGIN][NEW] Status: ${res.statusCode}'); - debugPrint('[AUTH][LOGIN][NEW] Response: ${res.body}'); - - Map decoded = {}; - try { - if (res.body.isNotEmpty) { - decoded = json.decode(res.body) as Map; - } - } catch (_) {} - - final bool ok = - decoded['success'] == true && - res.statusCode >= 200 && - res.statusCode < 300; - // REAL shape: { success, token, user:{ authname, contactno, email, userid, - // profile:{ userid, displayname, phone, hubid, applocationid, - // availabilitystatus, rating, ... } } } (the pasted doc was wrong). - final Map user = (decoded['user'] is Map) - ? decoded['user'] as Map - : (decoded['data'] is Map - ? decoded['data'] as Map - : {}); - final Map profile = (user['profile'] is Map) - ? user['profile'] as Map - : {}; - // Prefer profile field, then top-level user field. - dynamic pick(String k) => profile[k] ?? user[k]; - - // Persist bearer token for all subsequent authenticated calls. - final String token = (decoded['token'] ?? '').toString(); - if (token.isNotEmpty) await ApiConfig.setToken(token); - - // displayname -> first/last name split (best effort). - final String displayName = (pick('displayname') ?? user['authname'] ?? '') - .toString() - .trim(); - final int spaceIdx = displayName.indexOf(' '); - final String firstName = spaceIdx > 0 - ? displayName.substring(0, spaceIdx) - : displayName; - final String lastName = spaceIdx > 0 - ? displayName.substring(spaceIdx + 1).trim() - : ''; - - final String availability = (pick('availabilitystatus') ?? 'Offline') - .toString(); - final int onduty = - (availability.toLowerCase() == 'offline' || availability.isEmpty) - ? 0 - : 1; - final userId = user['userid'] ?? profile['userid'] ?? 0; - final phone = user['contactno'] ?? profile['phone'] ?? contactNo; - - // Map the new payload into the legacy `details` shape loginParsed reads. - final legacy = { - 'status': ok, - 'code': ok ? 200 : (decoded['code'] ?? 400), - 'message': decoded['message']?.toString() ?? '', - 'details': { - 'userid': userId, - 'riderid': userId, - 'displayname': displayName, - 'username': displayName, - 'firstname': firstName, - 'lastname': lastName, - 'contactno': phone, - 'email': user['email'] ?? '', - // hub/app-location scoping. - 'hubid': profile['hubid'] ?? 0, - 'locationid': profile['applocationid'] ?? profile['hubid'] ?? 0, - 'applocationid': profile['applocationid'] ?? 0, - 'rating': pick('rating') ?? 0, - 'availabilitystatus': availability, - 'onduty': onduty, - // Fields the new contract does not provide yet — safe defaults. - 'shiftid': 0, - 'logid': 0, - 'partnerid': 0, - 'configid': 0, - 'tenantid': 0, - 'pickupradius': 100, - 'starttime': '', - 'endtime': '', - }, - }; - // Also persist contactno directly (rider logs read it from prefs). - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('contactno', phone.toString()); - - return http.Response( - json.encode(legacy), - ok ? 200 : res.statusCode, - headers: {'content-type': 'application/json'}, - ); - } - - /// NEW API: POST /miler/login { phone } - /// - /// Account-existence precheck. 200 means the phone belongs to an active miler - /// account that already has a PIN on file, so the rider should go straight to - /// the MPIN screen — no OTP, no Create-MPIN (which would overwrite the PIN - /// they were given). 404 means the number isn't registered. - /// - /// Returns true only for an existing, active miler account. - Future milerAccountExists(String contactNo) async { - - try { - final uri = Uri.parse(ApiConfig.url('/miler/login')); - final res = await http - .post( - uri, - headers: const { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: json.encode({ - 'phone': contactNo, - 'configid': MilerApi.configId, - }), - ) - .timeout(const Duration(seconds: 15)); - debugPrint( - '[AUTH][PRECHECK] $contactNo -> ${res.statusCode} ${res.body}', - ); - if (res.statusCode < 200 || res.statusCode >= 300) return false; - final decoded = json.decode(res.body); - return decoded is Map && decoded['success'] == true; - } catch (e) { - debugPrint('[AUTH][PRECHECK] error: $e'); - return false; - } - } - - /// NEW API: PUT /miler/device-token { device_token } - /// - /// verify-pin only registers the token at login time, but FCM rotates tokens - /// independently of the session. Without re-registering, the backend keeps - /// pushing to a dead token and the rider stops seeing new-assignment alerts - /// with no visible symptom. Call this whenever the token changes. - Future saveDeviceToken(String fcmToken) async { - if (fcmToken.isEmpty) return false; - try { - final uri = Uri.parse(ApiConfig.url('/miler/device-token')); - final res = await http - .put( - uri, - headers: await ApiConfig.authHeaders(), - body: json.encode({'device_token': fcmToken}), - ) - .timeout(const Duration(seconds: 15)); - debugPrint('[AUTH][DEVICE_TOKEN] status=${res.statusCode}'); - return res.statusCode >= 200 && res.statusCode < 300; - } catch (e) { - debugPrint('[AUTH][DEVICE_TOKEN] error: $e'); - return false; - } - } - - // Convenience: send using a Login model body - Future loginWith(Login request) async { - final uri = Uri.parse( - 'https://jupiter.doormile.app/live/api/v2/users/rider/login', - ); - final body = request.toJson(); - debugPrint('[AUTH][LOGIN] URL: ${uri.toString()}'); - debugPrint('[AUTH][LOGIN] Body: ${json.encode(body)}'); - final res = await http.post( - uri, - headers: {'Content-Type': 'application/json'}, - body: json.encode(body), - ); - debugPrint('[AUTH][LOGIN] Status: ${res.statusCode}'); - debugPrint('[AUTH][LOGIN] Response: ${res.body}'); - return res; - } - - // Convenience: parsed response as Login model - Future loginParsed({ - required String contactNo, - required String deviceType, - required int configId, - required String deviceId, - required String fcmToken, - int? pin, - String? pinRaw, - }) async { - final res = await login( - contactNo: contactNo, - deviceType: deviceType, - configId: configId, - deviceId: deviceId, - fcmToken: fcmToken, - pin: pin, - pinRaw: pinRaw, - ); - final Map jsonMap = res.body.isNotEmpty - ? json.decode(res.body) as Map - : {}; - debugPrint('[AUTH] Raw Login JSON: $jsonMap'); - - if (jsonMap.containsKey('details')) { - final details = jsonMap['details']; - - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('userid', details['userid'] ?? 0); - await prefs.setInt('userId', details['userid'] ?? 0); - await prefs.setInt('shiftid', details['shiftid'] ?? 0); - await prefs.setInt('shiftId', details['shiftid'] ?? 0); - await prefs.setInt('logid', details['logid'] ?? 0); - await prefs.setInt('logId', details['logid'] ?? 0); - await prefs.setInt('riderid', details['riderid'] ?? 0); - await prefs.setInt('partnerid', details['partnerid'] ?? 0); - await prefs.setInt('partnerId', details['partnerid'] ?? 0); - await prefs.setInt('configid', details['configid'] ?? 0); - await prefs.setInt('logseconds', details['logseconds'] ?? 0); - - await prefs.setInt('locationid', details['locationid'] ?? 0); - await prefs.setInt('tenantid', details['tenantid'] ?? 0); - await prefs.setInt('applocationid', details['applocationid'] ?? 0); - - final String fcm = (details['userfcmtoken'] ?? '').toString(); - if (fcm.isNotEmpty) { - await prefs.setString('userfcmtoken', fcm); - } - - // Persist rider name variants for downstream usage (e.g. rider logs) - final String firstName = (details['firstname'] ?? '').toString(); - final String lastName = (details['lastname'] ?? '').toString(); - final String apiUsername = (details['username'] ?? '').toString(); - final String combinedName = ('$firstName $lastName').trim(); - - if (apiUsername.isNotEmpty) { - await prefs.setString('username', apiUsername); - } else if (combinedName.isNotEmpty) { - await prefs.setString('username', combinedName); - } - if (firstName.isNotEmpty) { - await prefs.setString('firstname', firstName); - } - if (lastName.isNotEmpty) { - await prefs.setString('lastname', lastName); - } - if (details['onduty'] != null) { - final int od = (details['onduty'] is num) - ? (details['onduty'] as num).toInt() - : int.tryParse('${details['onduty']}') ?? 0; - await prefs.setInt('onduty', od); - } - // Persist rider payout config (per-kilometer fuel/rider charge) if provided - if (details.containsKey('fuelcharge')) { - final double fuelCharge = - double.tryParse('${details['fuelcharge']}') ?? 0.0; - await prefs.setDouble('fuelcharge', fuelCharge); - } - // Backward compatibility with older field names - if (details.containsKey('firstmilecharge')) { - final double firstMileCharge = - double.tryParse('${details['firstmilecharge']}') ?? 0.0; - await prefs.setDouble('firstmilecharge', firstMileCharge); - } else if (details.containsKey('firstmilecharges')) { - final double firstMileCharge = - double.tryParse('${details['firstmilecharges']}') ?? 0.0; - await prefs.setDouble('firstmilecharge', firstMileCharge); - } - // Save shift window for header display - if (details['starttime'] != null) { - await prefs.setString('starttime', details['starttime'].toString()); - } - await prefs.setString('endtime', details['endtime'].toString()); - - // Save pickup radius for geofencing (default 100m if not provided) - if (details['pickupradius'] != null) { - final int radius = (details['pickupradius'] is num) - ? (details['pickupradius'] as num).toInt() - : int.tryParse('${details['pickupradius']}') ?? 100; - await prefs.setInt('pickupradius', radius); - debugPrint('[AUTH] Saved pickupradius: $radius meters'); - } else { - await prefs.setInt('pickupradius', 100); // Default - debugPrint('[AUTH] Saved default pickupradius: 100 meters'); - } - - debugPrint( - '[AUTH] SharedPrefs Saved: ' - 'userid=${details['userid']}, shiftid=${details['shiftid']}, ' - 'logid=${details['logid']}, riderid=${details['riderid']},' - 'partnerid=${details['partnerid']}, configid=${details['configid']}', - ); - - // Rider log creation is deferred until the rider goes ON duty. - // - // NOTE: We intentionally do NOT auto-navigate from here anymore. - // Navigation after login / PIN verification is handled in the UI flows - // (e.g. MPIN screen) so that riders cannot reach the homepage before - // successfully entering a valid PIN. - } - - return Login.fromJson(jsonMap); - } - - Future updatePin({ - required int userId, - required int pin, - }) async { - // ── There is no rider-facing set-PIN endpoint, and that is deliberate ── - // - // The only PIN-write route on the backend is `POST /miler/reset-pin`, and - // it requires an ADMIN token. It was once open, and reset-pin followed by - // verify-pin took over any rider account given nothing but a phone number. - // The app must not call it; rider PIN resets go through ops. - // - // So this stays a no-op success: the Create-MPIN screen's flow completes - // and the PIN the account was issued with remains the one that works. - // Making it fail instead would strand a rider on a screen with no way - // forward, which is worse and no more honest. - ApiConfig.logGap( - 'updatePin', - 'No rider-facing set-PIN route; reset-pin is admin-only by design.', - ); - return http.Response( - json.encode(ApiConfig.okEnvelope('pin is managed by ops')), - 200, - headers: {'content-type': 'application/json'}, - ); - } -} +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:miler/Models/login/login.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthProvider { + /// Null rather than 0 for anything unparseable, so a missing tenant falls + /// through to the build's value instead of masquerading as tenant zero. + static int? _toInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString().trim()); + } + + /// Null for zero, so an absent tenant falls through the `??` chain instead of + /// stopping it as tenant zero — which is not a tenant, but is truthy enough + /// to look like one. + static int? _nonZero(int v) => v == 0 ? null : v; + + /// What to tell the rider when `verify-pin` did not succeed. + /// + /// The server's own sentence when it sent one — `incorrect PIN` is precise + /// and actionable. Otherwise the status code and a short slice of the body, + /// because "something between this phone and the server said no" is a real + /// answer and "your PIN is wrong" is a false one. + static String _failureText(http.Response res, Map decoded) { + final said = (decoded['message'] ?? decoded['error'] ?? '') + .toString() + .trim(); + if (said.isNotEmpty) return said; + + final body = res.body.trim(); + final preview = body.length > 120 ? '${body.substring(0, 120)}…' : body; + if (preview.isEmpty) { + return 'The server returned HTTP ${res.statusCode} with no message.'; + } + return 'The server returned HTTP ${res.statusCode}: $preview'; + } + + /// Every spelling and nesting this contract has used for the bearer token. + /// + /// Order is deliberate: the envelope first, because that is where the handler + /// puts it today, then the two payload maps for deployments that nest it. + static const List _tokenKeys = [ + 'token', + 'access_token', + 'accessToken', + 'authtoken', + 'authToken', + 'jwt', + 'idToken', + 'id_token', + 'bearer', + ]; + + /// First non-empty token found across the response's envelope, its `data` + /// child, and the two payload maps — or `''`. Never throws on a shape it does + /// not recognise. + @visibleForTesting + static String tokenFromResponse(Map? envelope, Map? user, Map? profile) => + _tokenIn(envelope, user, profile); + + static String _tokenIn(Map? envelope, Map? user, Map? profile) { + final candidates = [ + if (envelope != null) envelope, + if (envelope != null && envelope['data'] is Map) envelope['data'] as Map, + if (user != null) user, + if (profile != null) profile, + ]; + for (final map in candidates) { + for (final key in _tokenKeys) { + final v = map[key]; + if (v == null) continue; + final s = v.toString().trim(); + if (s.isNotEmpty && s.toLowerCase() != 'null') return s; + } + } + return ''; + } + + Future login({ + required String contactNo, + required String deviceType, + required int configId, + required String deviceId, + required String fcmToken, + int? pin, + String? pinRaw, + }) async { + // The legacy `jupiter.doormile.app/.../rider/login` path that used to sit + // behind a flag here is gone with the rest of the old backend. + return _loginNew( + contactNo: contactNo, + pin: pin, + pinRaw: pinRaw, + fcmToken: fcmToken, + ); + } + + /// NEW API: POST /miler/verify-pin { phone, pin, device_token } + /// -> { success, token, data:{ userid, displayname, phone, hubid, + /// availabilitystatus, rating } } + /// + /// We store the bearer token, then return a synthetic http.Response whose body + /// is the LEGACY `{status, details:{...}}` shape so [loginParsed] persists the + /// same SharedPreferences keys it always has — no change to the auth UI flow. + /// + /// `device_token` is REQUIRED for the rider to receive push at all: the backend + /// stores it on the miler profile at verify-pin time and + /// AssignMilerToBooking pushes "New Pickup Assigned" to exactly that token. + /// Omitting it leaves the profile's token empty and silently makes the app + /// poll-only. + Future _loginNew({ + required String contactNo, + int? pin, + String? pinRaw, + String? fcmToken, + }) async { + final uri = Uri.parse(ApiConfig.url('/miler/verify-pin')); + // The backend bcrypt-compares the PIN as a STRING, so a leading zero is + // significant. Prefer the raw text the rider typed — round-tripping through + // int drops it ("0512" -> 512 -> "512") and fails a valid PIN. + final String? pinValue = (pinRaw != null && pinRaw.isNotEmpty) + ? pinRaw + : pin?.toString(); + final body = { + 'phone': contactNo, + if (pinValue != null) 'pin': pinValue, + // Riders live in partition 1001. It defaults server-side, so omitting it + // appeared to work — but a miler row created without it can never log in, + // and sending it explicitly is the only way the app and the console agree + // about which partition a rider belongs to. + 'configid': MilerApi.configId, + // Which operating company this build signs riders in for. Omitted + // entirely when the build declares none — see [MilerApi.tenantId]. + if (MilerApi.hasTenantId) 'tenantid': MilerApi.tenantId, + if (fcmToken != null && fcmToken.isNotEmpty) 'device_token': fcmToken, + }; + debugPrint('[AUTH][LOGIN][NEW] URL: $uri'); + debugPrint('[AUTH][LOGIN][NEW] Body: ${json.encode(body)}'); + + // ── The mock is a *response*, not a shortcut ── + // + // This used to `return` the mocked payload directly, and everything that + // makes a payload usable happens BELOW: the bearer token is stored there, + // and the response is mapped into the `{status, details:{…}}` envelope that + // `Login.fromJson` reads. Returning early skipped both. + // + // So on the mock path `status` was never set — and an absent `status` + // parses as **false** — and no token was ever stored. The sign-in saw a + // rejected login with no HTTP status behind it and told the rider + // "Login failed", for every phone number and every MPIN. `MOCK_BACKEND` + // defaults to on in debug, which is how the app is run, so that was the + // state of sign-in for anyone not building release. + // + // The whole point of rule 2 in [MockBackend] is that it intercepts the + // *transport* and nothing downstream can tell the difference. An early + // return is not an intercepted transport; it is a second implementation of + // this function that nobody was testing. One `res`, one path. + // + // Signing in with nothing to sign in to. This is the one call that cannot + // be intercepted in [MilerApi] — auth posts its own request, because it + // runs before there is a token for the shared transport to attach. + final mocked = MockBackend.respond('POST', '/miler/verify-pin', body: body); + + final http.Response res = mocked != null + ? http.Response( + json.encode(mocked), + 200, + headers: const {'content-type': 'application/json'}, + ) + : await http.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: json.encode(body), + ); + debugPrint('[AUTH][LOGIN][NEW] Status: ${res.statusCode}'); + debugPrint('[AUTH][LOGIN][NEW] Response: ${res.body}'); + + Map decoded = {}; + try { + if (res.body.isNotEmpty) { + decoded = json.decode(res.body) as Map; + } + } catch (_) {} + + final bool ok = + decoded['success'] == true && + res.statusCode >= 200 && + res.statusCode < 300; + // REAL shape: { success, token, user:{ authname, contactno, email, userid, + // profile:{ userid, displayname, phone, hubid, applocationid, + // availabilitystatus, rating, ... } } } (the pasted doc was wrong). + final Map user = (decoded['user'] is Map) + ? decoded['user'] as Map + : (decoded['data'] is Map + ? decoded['data'] as Map + : {}); + final Map profile = (user['profile'] is Map) + ? user['profile'] as Map + : {}; + // Prefer profile field, then top-level user field, then the envelope. + // + // The envelope is in the chain for the tenant pair: `verify-pin` returns + // `tenantid`/`tenantname` on `user`, but a handler that later moves them + // to the top level should not silently drop the rider back to the default + // line. Three places to look costs nothing and removes a whole class of + // "why is this rider on the wrong flow" report. + dynamic pick(String k) => profile[k] ?? user[k] ?? decoded[k]; + + // ── Find the bearer token wherever this deployment puts it ── + // + // This read `decoded['token']` and nothing else, and the sign-in then used + // "is there a token in prefs?" as its test for *whether the PIN was right*. + // So a backend that nests the token one level down — `data.token`, + // `user.token`, `access_token`, any of the spellings this contract has + // worn — produced a verify-pin that **succeeded** and a sign-in that + // reported **"Login failed"**. For every rider, on every attempt, with the + // server agreeing the PIN was correct. + // + // Looking in nine places costs nothing and removes the whole class of + // failure. [_tokenIn] returns '' when there is genuinely no token, which is + // a different fact from a rejected PIN and is now reported as one. + final String token = _tokenIn(decoded, user, profile); + if (token.isNotEmpty) { + await ApiConfig.setToken(token); + } else if (ok) { + debugPrint( + '[AUTH][LOGIN][NEW] server said success but carried no token; ' + 'top-level keys: ${decoded.keys.toList()}', + ); + } + + // displayname -> first/last name split (best effort). + final String displayName = (pick('displayname') ?? user['authname'] ?? '') + .toString() + .trim(); + final int spaceIdx = displayName.indexOf(' '); + final String firstName = spaceIdx > 0 + ? displayName.substring(0, spaceIdx) + : displayName; + final String lastName = spaceIdx > 0 + ? displayName.substring(spaceIdx + 1).trim() + : ''; + + final String availability = (pick('availabilitystatus') ?? 'Offline') + .toString(); + final int onduty = + (availability.toLowerCase() == 'offline' || availability.isEmpty) + ? 0 + : 1; + final userId = user['userid'] ?? profile['userid'] ?? 0; + final phone = user['contactno'] ?? profile['phone'] ?? contactNo; + + // Map the new payload into the legacy `details` shape loginParsed reads. + final legacy = { + 'status': ok, + // Whether THIS call produced a session, as distinct from whether the + // credentials were accepted. The sign-in needs to tell those apart to + // say anything useful. + 'tokenstored': token.isNotEmpty, + // ── The transport failure used to vanish here ── + // + // This was `decoded['code'] ?? 400`. A proxy 502, a captive portal's + // redirect page, a gateway timeout — anything whose body is not the JSON + // this handler speaks — decoded to `{}`, so the code became a hard-coded + // 400 and the message became `''`. The sign-in then reported all of it + // as **"Login failed"**, which the rider reads as "my PIN is wrong". + // + // The real status now survives, and so does a slice of whatever came + // back, so a failing handset can say what it is actually being told + // instead of only that it is unhappy. + 'code': ok ? 200 : res.statusCode, + 'httpstatus': res.statusCode, + 'message': ok ? '' : _failureText(res, decoded), + 'details': { + 'userid': userId, + 'riderid': userId, + 'displayname': displayName, + 'username': displayName, + 'firstname': firstName, + 'lastname': lastName, + 'contactno': phone, + 'email': user['email'] ?? '', + // hub/app-location scoping. + 'hubid': profile['hubid'] ?? 0, + 'locationid': profile['applocationid'] ?? profile['hubid'] ?? 0, + 'applocationid': profile['applocationid'] ?? 0, + 'rating': pick('rating') ?? 0, + 'availabilitystatus': availability, + 'onduty': onduty, + // Fields the new contract does not provide yet — safe defaults. + 'shiftid': 0, + 'logid': 0, + 'partnerid': 0, + 'configid': 0, + // ── Which line of work this rider is on ── + // + // The server's answer first, the build's declared tenant only when + // there is no answer to have. `verify-pin` returns both halves now, so + // this is a live path rather than a seam: a rostered rider's account + // decides his flow, and a build flag can no longer override it. + // + // This is the switch that decides whether the rider gets the Logistics + // flow or the Milk Man flow — see [ServiceProfile]. "Which work am I + // doing today?" must be answered by the hub that rostered him, not by + // whoever compiled the APK. + // The token's claim sits between the body and the build flag: it is the + // same fact from the same source, signed by the server, and it is + // present on every deployment — including ones whose `verify-pin` does + // not put the tenant in the body. That was the live case: a login + // carrying tenant 13 in its token, a body with no tenant at all, and + // every rider falling through to the default line. + 'tenantid': + _toInt(pick('tenantid')) ?? + _nonZero(ApiConfig.tenantIdFromToken(token)) ?? + MilerApi.tenantId, + 'tenantname': (pick('tenantname') ?? pick('tenantcode') ?? '') + .toString(), + 'pickupradius': 100, + 'starttime': '', + 'endtime': '', + }, + }; + // Also persist contactno directly (rider logs read it from prefs). + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('contactno', phone.toString()); + + return http.Response( + json.encode(legacy), + ok ? 200 : res.statusCode, + headers: {'content-type': 'application/json'}, + ); + } + + /// NEW API: POST /miler/login { phone } + /// + /// Account-existence precheck. 200 means the phone belongs to an active miler + /// account that already has a PIN on file, so the rider should go straight to + /// the MPIN screen — no OTP, no Create-MPIN (which would overwrite the PIN + /// they were given). 404 means the number isn't registered. + /// + /// Returns true only for an existing, active miler account. + /// Whether [contactNo] is an active miler account: `true`, `false`, or + /// **null when the question could not be asked**. + /// + /// ── "No" and "I don't know" are not the same answer ── + /// + /// This returned a plain `bool` and answered `false` for a timeout, a DNS + /// failure, a 502, and a body it could not parse. `precheckPhone` reads a + /// `false` as *this number has no account* and routes the rider into the + /// OTP → Create-MPIN flow — which cannot set a PIN (see [updatePin]) but + /// tells him it did. So one flaky request took a rider with a perfectly good + /// account and walked him into a dead end that locks him out and looks like + /// his fault. + /// + /// Null now means unknown, and the caller sends unknown to the MPIN screen — + /// the destination that is right for every rider who already has an account, + /// and the one screen that can report what actually went wrong. + Future milerAccountExists(String contactNo) async { + try { + final uri = Uri.parse(ApiConfig.url('/miler/login')); + // Every phone number has an account when there is no directory to ask. + if (MockBackend.enabled) { + debugPrint('[MOCK] POST /miler/login -> account exists'); + return true; + } + final res = await http + .post( + uri, + headers: const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: json.encode({ + 'phone': contactNo, + 'configid': MilerApi.configId, + if (MilerApi.hasTenantId) 'tenantid': MilerApi.tenantId, + }), + ) + .timeout(const Duration(seconds: 15)); + debugPrint( + '[AUTH][PRECHECK] $contactNo -> ${res.statusCode} ${res.body}', + ); + // 404 is a real "no such account"; 5xx and anything else is the server + // failing to answer, which is not evidence about the rider. + if (res.statusCode == 404) return false; + if (res.statusCode < 200 || res.statusCode >= 300) { + if (res.statusCode == 401 || res.statusCode == 403) return false; + return null; + } + final decoded = json.decode(res.body); + if (decoded is! Map) return null; + return decoded['success'] == true; + } catch (e) { + debugPrint('[AUTH][PRECHECK] could not reach the directory: $e'); + return null; + } + } + + /// NEW API: PUT /miler/device-token { device_token } + /// + /// verify-pin only registers the token at login time, but FCM rotates tokens + /// independently of the session. Without re-registering, the backend keeps + /// pushing to a dead token and the rider stops seeing new-assignment alerts + /// with no visible symptom. Call this whenever the token changes. + Future saveDeviceToken(String fcmToken) async { + if (fcmToken.isEmpty) return false; + try { + final uri = Uri.parse(ApiConfig.url('/miler/device-token')); + final res = await http + .put( + uri, + headers: await ApiConfig.authHeaders(), + body: json.encode({'device_token': fcmToken}), + ) + .timeout(const Duration(seconds: 15)); + debugPrint('[AUTH][DEVICE_TOKEN] status=${res.statusCode}'); + return res.statusCode >= 200 && res.statusCode < 300; + } catch (e) { + debugPrint('[AUTH][DEVICE_TOKEN] error: $e'); + return false; + } + } + + // Convenience: send using a Login model body + Future loginWith(Login request) async { + final uri = Uri.parse( + 'https://jupiter.doormile.app/live/api/v2/users/rider/login', + ); + final body = request.toJson(); + debugPrint('[AUTH][LOGIN] URL: ${uri.toString()}'); + debugPrint('[AUTH][LOGIN] Body: ${json.encode(body)}'); + final res = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode(body), + ); + debugPrint('[AUTH][LOGIN] Status: ${res.statusCode}'); + debugPrint('[AUTH][LOGIN] Response: ${res.body}'); + return res; + } + + // Convenience: parsed response as Login model + Future loginParsed({ + required String contactNo, + required String deviceType, + required int configId, + required String deviceId, + required String fcmToken, + int? pin, + String? pinRaw, + }) async { + final res = await login( + contactNo: contactNo, + deviceType: deviceType, + configId: configId, + deviceId: deviceId, + fcmToken: fcmToken, + pin: pin, + pinRaw: pinRaw, + ); + final Map jsonMap = res.body.isNotEmpty + ? json.decode(res.body) as Map + : {}; + debugPrint('[AUTH] Raw Login JSON: $jsonMap'); + + // ── Only a successful login may rewrite who the rider is ── + // + // This ran on `containsKey('details')` alone, and the failure envelope + // carries a `details` map too — full of the zeros and empty strings used as + // safe defaults. So a rejected PIN, a 502, or a `refreshSession` that fired + // without one wrote `userid=0`, `tenantid=0` and a blank name straight over + // a perfectly good signed-in session. The rider was then on the fallback + // line with no identity, from a call that had failed. + final bool succeeded = jsonMap['status'] == true; + + if (succeeded && jsonMap.containsKey('details')) { + final details = jsonMap['details']; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('userid', details['userid'] ?? 0); + await prefs.setInt('userId', details['userid'] ?? 0); + await prefs.setInt('shiftid', details['shiftid'] ?? 0); + await prefs.setInt('shiftId', details['shiftid'] ?? 0); + await prefs.setInt('logid', details['logid'] ?? 0); + await prefs.setInt('logId', details['logid'] ?? 0); + await prefs.setInt('riderid', details['riderid'] ?? 0); + await prefs.setInt('partnerid', details['partnerid'] ?? 0); + await prefs.setInt('partnerId', details['partnerid'] ?? 0); + await prefs.setInt('configid', details['configid'] ?? 0); + await prefs.setInt('logseconds', details['logseconds'] ?? 0); + + await prefs.setInt('locationid', details['locationid'] ?? 0); + await prefs.setInt('tenantid', details['tenantid'] ?? 0); + await prefs.setInt('applocationid', details['applocationid'] ?? 0); + + // ── Which business this rider works for ── + // + // `tenantid` was already persisted here and sent back on rider logs; it + // is now also what picks the rider's whole flow — parcel logistics or a + // subscription meal run. See [TenantController]. + // + // The name is stored alongside it when the backend sends one, because a + // name can be matched without shipping a release for every new tenant id + // and it is the field a human can check against the admin console. + final String tenantName = + (details['tenantname'] ?? + details['tenantcode'] ?? + details['tenant'] ?? + '') + .toString() + .trim(); + if (tenantName.isNotEmpty) { + await prefs.setString(TenantController.kTenantName, tenantName); + } else { + // A rider signing in to a different account must not inherit the last + // one's tenant name. + await prefs.remove(TenantController.kTenantName); + } + await TenantController.to.load(); + + final String fcm = (details['userfcmtoken'] ?? '').toString(); + if (fcm.isNotEmpty) { + await prefs.setString('userfcmtoken', fcm); + } + + // Persist rider name variants for downstream usage (e.g. rider logs) + final String firstName = (details['firstname'] ?? '').toString(); + final String lastName = (details['lastname'] ?? '').toString(); + final String apiUsername = (details['username'] ?? '').toString(); + final String combinedName = ('$firstName $lastName').trim(); + + if (apiUsername.isNotEmpty) { + await prefs.setString('username', apiUsername); + } else if (combinedName.isNotEmpty) { + await prefs.setString('username', combinedName); + } + if (firstName.isNotEmpty) { + await prefs.setString('firstname', firstName); + } + if (lastName.isNotEmpty) { + await prefs.setString('lastname', lastName); + } + if (details['onduty'] != null) { + final int od = (details['onduty'] is num) + ? (details['onduty'] as num).toInt() + : int.tryParse('${details['onduty']}') ?? 0; + await prefs.setInt('onduty', od); + } + // Persist rider payout config (per-kilometer fuel/rider charge) if provided + if (details.containsKey('fuelcharge')) { + final double fuelCharge = + double.tryParse('${details['fuelcharge']}') ?? 0.0; + await prefs.setDouble('fuelcharge', fuelCharge); + } + // Backward compatibility with older field names + if (details.containsKey('firstmilecharge')) { + final double firstMileCharge = + double.tryParse('${details['firstmilecharge']}') ?? 0.0; + await prefs.setDouble('firstmilecharge', firstMileCharge); + } else if (details.containsKey('firstmilecharges')) { + final double firstMileCharge = + double.tryParse('${details['firstmilecharges']}') ?? 0.0; + await prefs.setDouble('firstmilecharge', firstMileCharge); + } + // Save shift window for header display + if (details['starttime'] != null) { + await prefs.setString('starttime', details['starttime'].toString()); + } + await prefs.setString('endtime', details['endtime'].toString()); + + // Save pickup radius for geofencing (default 100m if not provided) + if (details['pickupradius'] != null) { + final int radius = (details['pickupradius'] is num) + ? (details['pickupradius'] as num).toInt() + : int.tryParse('${details['pickupradius']}') ?? 100; + await prefs.setInt('pickupradius', radius); + debugPrint('[AUTH] Saved pickupradius: $radius meters'); + } else { + await prefs.setInt('pickupradius', 100); // Default + debugPrint('[AUTH] Saved default pickupradius: 100 meters'); + } + + debugPrint( + '[AUTH] SharedPrefs Saved: ' + 'userid=${details['userid']}, shiftid=${details['shiftid']}, ' + 'logid=${details['logid']}, riderid=${details['riderid']},' + 'partnerid=${details['partnerid']}, configid=${details['configid']}', + ); + + // Rider log creation is deferred until the rider goes ON duty. + // + // NOTE: We intentionally do NOT auto-navigate from here anymore. + // Navigation after login / PIN verification is handled in the UI flows + // (e.g. MPIN screen) so that riders cannot reach the homepage before + // successfully entering a valid PIN. + } + + return Login.fromJson(jsonMap); + } + + Future updatePin({ + required int userId, + required int pin, + }) async { + // ── There is no rider-facing set-PIN endpoint, and that is deliberate ── + // + // The only PIN-write route on the backend is `POST /miler/reset-pin`, and + // it requires an ADMIN token. It was once open, and reset-pin followed by + // verify-pin took over any rider account given nothing but a phone number. + // The app must not call it; rider PIN resets go through ops. + // + // So this stays a no-op success: the Create-MPIN screen's flow completes + // and the PIN the account was issued with remains the one that works. + // Making it fail instead would strand a rider on a screen with no way + // forward, which is worse and no more honest. + // ── It used to answer 200 ── + // + // "Making it fail instead would strand a rider on a screen with no way + // forward, which is worse and no more honest." Half of that was right and + // the conclusion was wrong. What the manufactured 200 actually did: + // + // 1. Create-MPIN told the rider his new MPIN was saved. + // 2. The app wrote it to `dbPin` locally. + // 3. The server never heard about it. + // 4. Every login from then on returned `incorrect PIN`, forever, with no + // way for the rider to tell that the PIN he was typing had never + // existed anywhere but his own handset. + // + // Being stranded on a screen that tells you who can help is not worse than + // that. It is the only version of this that a rider can act on. + ApiConfig.logGap( + 'updatePin', + 'No rider-facing set-PIN route; reset-pin is admin-only by design. ' + 'Refusing rather than reporting a write that did not happen.', + ); + return http.Response( + json.encode({ + 'status': false, + 'code': 403, + 'message': + 'Your MPIN is issued by your hub and cannot be changed from the ' + 'app. Ask your supervisor to reset it, then sign in with the MPIN ' + 'they give you.', + }), + 403, + headers: {'content-type': 'application/json'}, + ); + } +} diff --git a/lib/providers/notifications/notificationservce.dart b/lib/providers/notifications/notificationservce.dart index 1a934b0..4527359 100644 --- a/lib/providers/notifications/notificationservce.dart +++ b/lib/providers/notifications/notificationservce.dart @@ -368,7 +368,7 @@ class NotificationServce { Text( title, style: const TextStyle( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/providers/pickup/pickup_provider.dart b/lib/providers/pickup/pickup_provider.dart index f857c8c..048433d 100644 --- a/lib/providers/pickup/pickup_provider.dart +++ b/lib/providers/pickup/pickup_provider.dart @@ -1,6 +1,6 @@ -import 'package:flutter/foundation.dart'; - import 'package:miler/data/api_config.dart'; +import 'package:miler/data/work_repository.dart'; +import 'package:miler/data/load_state.dart'; import 'package:miler/data/miler_api.dart'; /// Reads the rider's bookings. @@ -26,25 +26,56 @@ class PickupProvider { /// The mapping lives in [ApiConfig.pickupFromBooking] rather than here: it is /// the one place that knows how a v1 booking becomes a stop, and the same /// translation is needed by anything else that receives a booking. + /// ── One request, however many screens ask ── + /// + /// Home, Bookings, Activity and the post-delivery screen all call this, and + /// each of them used to produce its own `GET /miler/bookings` — on mount, on + /// tab switch, and on every poll. Four timers, four copies of the day, and + /// four ideas of what had been accepted, reconciled only by whichever + /// happened to refresh last. + /// + /// They all go through [WorkRepository] now, which collapses concurrent + /// callers into one request, keeps one copy, and drops a response that lands + /// out of order. Nothing at the call sites changed: this still returns a list + /// or throws, which is the contract the screens were written against. + /// + /// The richer answer — loading, empty, offline, unavailable — is on the + /// repository for screens that want to render it properly rather than + /// flattening it into an exception. See [LoadState]. Future> _bookings({String? status}) async { - final res = await MilerApi.bookings(status: status); - if (!res.ok) { - throw Exception('Failed (${res.status})${res.message.isEmpty ? '' : ': ${res.message}'}'); + // A filtered read is a different question and is not the one the shared + // copy answers. Nothing calls this with a status today; if something does, + // it gets its own request rather than silently receiving the whole day. + if (status != null && status.isNotEmpty) { + final res = await MilerApi.bookings(status: status); + if (!res.ok) { + throw Exception( + 'Failed (${res.status})' + '${res.message.isEmpty ? '' : ': ${res.message}'}', + ); + } + return ApiConfig.pickupsFromBookings(res.list); } - final mapped = ApiConfig.pickupsFromBookings(res.list); - debugPrint('[BOOKINGS] ${res.list.length} rows → ${mapped.length} stops'); - if (mapped.isEmpty && res.list.isNotEmpty) { - // The rows arrived and none of them survived translation, which is a - // field-name mismatch rather than an empty day. Worth saying out loud — - // it is the difference between "no work" and "the adapter is wrong". - ApiConfig.logGap( - 'pickupFromBooking', - '${res.list.length} bookings returned but none mapped — check the ' - 'field names against a real payload.', - ); - } - return mapped; + final state = await WorkRepository.instance.load(); + return switch (state) { + LoadData>>(:final value) => value, + // A real empty day. The screens render their own empty state from this. + LoadEmpty>>() => const [], + // No endpoint for this line of work — neither waiting nor retrying helps, + // so it must not arrive as an empty day. See [LineNotServedException]. + LoadUnavailable>>() => + throw const LineNotServedException(), + LoadFailure>>(:final kind, :final message) => + throw Exception( + message.isEmpty ? 'Could not load your work ($kind)' : message, + ), + // Only reachable if the repository handed back its pre-load state, which + // it does not. Treated as a failure rather than as an empty day. + LoadLoading>>() => throw Exception( + 'Still loading', + ), + }; } /// Everything the hub has put in front of this rider today. @@ -57,3 +88,18 @@ class PickupProvider { /// The stops he has accepted. Future> getPickupQueuesPicked() => _bookings(); } + +/// The rider's line of work has no backend to ask. +/// +/// Distinct from an empty day and from a failed request, because the rider's +/// answer is different in each case: wait, retry, or "your hub has not switched +/// this on yet". Screens catch this to render the unavailable state rather than +/// the empty one. +class LineNotServedException implements Exception { + const LineNotServedException(); + + @override + String toString() => + 'This line of work is not served by the backend yet — there is no ' + 'bookings endpoint for it. See ServiceProfile.hasBookingsEndpoint.'; +} diff --git a/lib/providers/pickuplog/pickuplog_provider.dart b/lib/providers/pickuplog/pickuplog_provider.dart index 49711e2..56d3c1c 100644 --- a/lib/providers/pickuplog/pickuplog_provider.dart +++ b/lib/providers/pickuplog/pickuplog_provider.dart @@ -2,7 +2,10 @@ import 'package:flutter/foundation.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/assignment_lookup.dart'; +import 'package:miler/data/lifecycle.dart'; +import 'package:miler/data/consignment_state.dart'; import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/accepted_store.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE PER-STOP WRITE PATH @@ -52,11 +55,14 @@ class CreatePickupLogProvider { Future?> createPickupLog( Map data, ) async { - final consignmentId = data['consignmentid'] ?? data['orderheaderid'] ?? - data['pickupid']; - final lat = double.tryParse('${data['latitude'] ?? data['riderslat'] ?? ''}'); - final lon = - double.tryParse('${data['longitude'] ?? data['riderslon'] ?? ''}'); + final consignmentId = + data['consignmentid'] ?? data['orderheaderid'] ?? data['pickupid']; + final lat = double.tryParse( + '${data['latitude'] ?? data['riderslat'] ?? ''}', + ); + final lon = double.tryParse( + '${data['longitude'] ?? data['riderslon'] ?? ''}', + ); if (consignmentId == null || lat == null || lon == null) { // Not enough to place the breadcrumb. Silently skipping is right — this @@ -104,7 +110,16 @@ class UpdatePickupProvider { Map envelope(ApiResult r) => r.ok ? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true}) - : {'status': false, 'code': r.status, 'message': r.message}; + : { + 'status': false, + 'code': r.status, + 'message': r.message, + // The backend's stable failure name, alongside the HTTP status + // this envelope has always called `code`. Callers that need to + // branch on *why* a write was refused read this one — message text + // is prose, and prose gets reworded. + 'errorcode': r.code, + }; switch (status) { case 'accepted': @@ -133,21 +148,150 @@ class UpdatePickupProvider { return envelope(r); case 'arrived': - return envelope(await MilerApi.reached(id, lat: lat, lon: lon)); + // ── A 200 is not proof the hub recorded an arrival ── + // + // Verified against production 21 Aug 2026: `reached` returns + // `{"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}` + // and the booking's status is unchanged afterwards. The endpoint is a + // no-op that reports success, so `Arrived_At_Pickup` never reaches the + // console — and the app, which read only `ok`, drew ARRIVED anyway. + // + // The transition is now read for what it proves. The envelope carries + // the verdict so the caller can advance the rider without the app + // claiming an agreement that does not exist. See [MilerLifecycle]. + final reached = await MilerApi.reached(id, lat: lat, lon: lon); + final arrival = MilerLifecycle.reached(reached); + MilerLifecycle.report('reached', arrival); + if (arrival.isUnconfirmed) { + ApiConfig.logGap( + 'reached', + 'booking $id: the call succeeded and the booking status did not ' + 'become Arrived_At_Pickup — ${arrival.evidence}. The hub ' + 'cannot show this rider as arrived. Backend deployment ' + 'mismatch; see MILER_API_REQUIREMENTS.md request 15.', + ); + } + return { + ...envelope(reached), + 'confirmed': arrival.isConfirmed, + 'serverstatus': arrival.bookingStatus.name, + 'evidence': arrival.evidence, + }; case 'Picked up': case 'picked': - return envelope(await MilerApi.pickupComplete(id, lat: lat, lon: lon)); + // ── The pivot returns the thing the delivery leg runs on ── + // + // `pickup-complete` is what *creates* the consignment, and `deliver` + // and `skip` are consignment routes. The response was being wrapped and + // discarded, and the row the rider works from on Deliveries is a + // snapshot taken before this call — so nothing downstream knew the + // consignment id, and pressing **Delivered** at the door was refused by + // the app itself. Recorded here, at the only moment it is guaranteed to + // be in hand. See [rememberConsignmentId]. + final picked = await MilerApi.pickupComplete(id, lat: lat, lon: lon); + // ── Which lifecycle the server is running, read from the server ── + // + // `Collected_By_Miler` + `next_action: start_delivery` is the new + // two-step flow; a bare `Out_for_Delivery` is compatibility mode, in + // which the pivot releases the consignment itself. Both are real, both + // ship today depending on a server-side flag, and the app must never + // mirror that flag — it reads which one happened. + final pivot = MilerLifecycle.pickupComplete(picked); + MilerLifecycle.report('pickup-complete', pivot); + if (pivot.isCompatibilityMode) { + ApiConfig.logGap( + 'pickup-complete', + 'booking $id went straight to Out_for_Delivery — the backend is ' + 'running compatibility mode (MILER_COLLECTED_STATE_ENABLED ' + 'off). The consignment IS out for delivery, so the console ' + 'showing Active is correct and the app must agree with it.', + ); + } + if (picked.ok) { + final newId = _consignmentIdFrom(picked); + if (newId.isNotEmpty) { + final orderKey = (data['orderid'] ?? id ?? '').toString().trim(); + await rememberConsignmentId(orderKey, newId); + // ── What the pivot now says about what happens next ── + // + // Since 21 Aug 2026 the response also carries `status` and + // `next_action`: a hyperlocal booking lands on + // `Collected_By_Miler` with `start_delivery`, and a hub-routed one + // on `Created` with `inward_at_hub`. Logged rather than acted on — + // the release is the rider's press, and the states it produces are + // read back from the consignment itself, never remembered from + // here. See `MyPickups.startRound`. + debugPrint( + '[PICKUP] booking $orderKey → consignment $newId ' + '(${_field(picked, 'status')}, next: ' + '${_field(picked, 'next_action')})', + ); + } else { + ApiConfig.logGap( + 'pickup-complete', + 'no consignment id in the response for booking $id — the ' + 'delivery leg will have to wait for the queue to carry it.', + ); + // The one thing that makes this diagnosable next time. Debug-only: + // a response body is not something to write into a release log. + if (kDebugMode) { + debugPrint('[PICKUP] pickup-complete body: ${picked.raw}'); + } + } + } + return { + ...envelope(picked), + 'confirmed': pivot.isConfirmed, + // What the stop actually became, in the consignment's own words. + // The caller stamps this rather than assuming 'picked' — the whole + // point of the exercise is that the rider's rung and the office's + // rung are the same rung. + 'consignmentstatus': + pivot.consignmentState == ConsignmentState.unknown + ? '' + : pivot.consignmentState.name, + 'nextaction': pivot.nextAction, + 'compatibilitymode': pivot.isCompatibilityMode, + 'evidence': pivot.evidence, + }; case 'delivered': case 'Delivered': + // ── This route keys on the CONSIGNMENT, not the booking ── + // + // Same trap as accept/reject and `bookingassignmentid`: two ids from + // two sequences, and passing the wrong one gets a 404 while the rider + // is shown success. This branch was passing `id`, which is the booking + // — so every delivery it sent was addressed to whatever consignment + // happened to share that number. + // + // The consignment exists only after `pickup-complete` has converted the + // booking; `MilerGetMyBookings` returns it and the stop adapter carries + // it through. Without one there is nothing to deliver *yet*, and saying + // so is better than guessing an id. + final consignmentId = + (data['consignmentid'] ?? data['consignmentId'] ?? '') + .toString() + .trim(); + if (consignmentId.isEmpty) { + ApiConfig.logGap( + 'update:delivered', + 'Booking $id has no consignmentid — it has not been collected yet, ' + 'so there is nothing to deliver.', + ); + return { + 'status': false, + 'message': 'This order has not been picked up yet.', + }; + } return envelope( await MilerApi.deliver( - id, + consignmentId, deliveredToName: (data['pickupcustomer'] ?? '').toString(), - // Only sent when there is one. The tenant flag decides whether it - // is required, and an empty string would fail a tenant that has it - // on while being pointless for one that does not. + // Optional: nothing generates a delivery OTP, so the handler + // records whether one was presented rather than checking it. A + // milk-run drop has none and must not invent one. otp: (data['otp'] ?? '').toString(), photoUrl: (data['dropimage'] ?? '').toString(), receiverSignatureUrl: (data['signature'] ?? '').toString(), @@ -185,11 +329,107 @@ class UpdatePickupProvider { return envelope(await MilerApi.setAvailability('On_Pickup')); default: + // ── An unmapped status is a failure, not a success ── + // + // This returned `okEnvelope()`: nothing left the phone, and the caller + // was told the write had landed. The rider watched the stop advance, + // the local stores recorded it, and the hub was never told — which is + // the single most expensive shape of bug this app can have, because + // every screen downstream then trusts a transition that does not exist + // server-side. + // + // It also defeats the typed-status handling upstream: a status this + // build does not recognise must never resolve to a settled state, and + // silently succeeding here is exactly that, one layer lower. ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".'); - return ApiConfig.okEnvelope(); + return { + 'status': false, + 'message': 'This app cannot record "$status" yet — nothing was sent.', + }; } } + /// The consignment id out of a `pickup-complete` response, whatever shape it + /// arrives in. + /// + /// Tolerant on purpose. The contract now names the field — `consignment_id`, + /// alongside `status` and `next_action` — but this ran for a long time + /// against a route whose response body was written down nowhere, riders are + /// on builds that met both, and the cost of a miss is somebody at a door + /// being told there is nothing to deliver. The search stays. + /// One shallow field off a mutation response, for logging. + /// + /// Deliberately not recursive and deliberately not stored: these values are + /// a description of a moment that has already passed by the time anything + /// would read them back. + String _field(ApiResult res, String key) { + final v = res.data[key]; + if (v == null || v is Map || v is List) return '?'; + final str = v.toString().trim(); + return str.isEmpty ? '?' : str; + } + + String _consignmentIdFrom(ApiResult res) { + // Searched rather than looked up, at every depth. The response body for + // this route is written down nowhere — not in the API reference, not in the + // flow doc — so a fixed list of key names is a guess, and the cost of + // guessing wrong is a rider standing at a door being told there is nothing + // to deliver. Anything whose key names a consignment and whose value is a + // plain scalar counts. + String found = ''; + + void walk(dynamic node, int depth) { + if (found.isNotEmpty || depth > 4 || node == null) return; + if (node is List) { + for (final item in node) { + walk(item, depth + 1); + } + return; + } + if (node is! Map) return; + + // Exact names first, so a nested `id` never wins over a real one. + for (final key in const [ + 'consignmentid', + 'consignmentId', + 'consignment_id', + 'consignmentno', + ]) { + final v = node[key]; + if (v != null && v is! Map && v is! List) { + final str = v.toString().trim(); + if (str.isNotEmpty && str != '0' && str != 'null') { + found = str; + return; + } + } + } + + // Then a `consignment` object, whose own id is the one wanted. + final nested = node['consignment']; + if (nested is Map) { + for (final key in const ['consignmentid', 'consignmentId', 'id']) { + final v = nested[key]; + if (v != null && v is! Map && v is! List) { + final str = v.toString().trim(); + if (str.isNotEmpty && str != '0') { + found = str; + return; + } + } + } + } + + for (final v in node.values) { + walk(v, depth + 1); + } + } + + walk(res.data, 0); + if (found.isEmpty) walk(res.raw, 0); + return found; + } + /// **Step 2** — what the rider actually took, measured at the door. /// /// Reads the verification screen's payload directly rather than taking a @@ -224,9 +464,7 @@ class UpdatePickupProvider { // and it keeps the chargeable total correct even though the per-parcel // figures are an even split rather than a measurement. final double each = totalWeight / count; - final parcels = [ - for (var i = 0; i < count; i++) ParcelEntry(weight: each), - ]; + final parcels = [for (var i = 0; i < count; i++) ParcelEntry(weight: each)]; final res = await MilerApi.submitParcels(bookingId, parcels); debugPrint( @@ -238,6 +476,85 @@ class UpdatePickupProvider { : {'status': false, 'code': res.status, 'message': res.message}; } + /// **Step 1b** — the FROM and TO the rider confirmed with the customer. + /// + /// Reads the shipment-capture screen's payload, same as [submitParcels] reads + /// the verification screen's, so there is one record of the door rather than + /// two argument lists to keep in step. + /// + /// Sent before the parcel and payment steps because `pickup-complete` builds + /// the consignment — its routing and its pricing zone — from these values, + /// and the handler refuses them once that conversion has happened. + Future?> submitAddresses( + Object bookingId, + Map capture, + ) async { + final Map? from = capture['from'] is Map ? capture['from'] as Map : null; + final Map? to = capture['to'] is Map ? capture['to'] as Map : null; + if (from == null && to == null) { + return ApiConfig.okEnvelope('no addresses captured'); + } + + String str(Map? m, String k) => (m?[k] ?? '').toString().trim(); + double? coord(Map? m, String k) => double.tryParse('${m?[k] ?? ''}'); + + final res = await MilerApi.updateBookingAddresses( + bookingId, + pickupAddress: str(from, 'address'), + pickupPincode: str(from, 'pincode'), + pickupLat: coord(from, 'lat'), + pickupLon: coord(from, 'lon'), + deliveryAddress: str(to, 'address'), + deliveryPincode: str(to, 'pincode'), + deliveryLat: coord(to, 'lat'), + deliveryLon: coord(to, 'lon'), + deliveryCity: str(to, 'city'), + ); + debugPrint( + '[ADDRESS] booking $bookingId ' + '→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}', + ); + return res.ok + ? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true}) + : {'status': false, 'code': res.status, 'message': res.message}; + } + + /// **Step 2b** — what this shipment costs, from the pricing table. + /// + /// Returns null when the call itself failed, and a [ShipmentQuote] with + /// `found: false` when the table simply has no rule for this combination. + /// The two are different things to tell a rider — "try again" versus "call + /// the hub, this cannot be priced here" — so they stay distinguishable, and + /// neither one produces a number. + Future fetchQuote({ + required double weight, + required String serviceType, + String? pickupPincode, + String? deliveryPincode, + String? category, + }) async { + final res = await MilerApi.checkPrice( + weight: weight, + serviceType: serviceType, + pickupPincode: pickupPincode, + deliveryPincode: deliveryPincode, + category: category, + ); + if (!res.ok) { + debugPrint('[PRICE] FAILED ${res.status} ${res.message}'); + return null; + } + final quote = ShipmentQuote.fromData( + res.map, + preferredCategory: category ?? '', + ); + debugPrint( + '[PRICE] ${quote.zone}/${quote.serviceType} ${quote.weight}kg → ' + '${quote.found ? '${quote.currency} ${quote.payable}' : quote.message}', + ); + return quote; + } + /// **Step 3** — the money, from the collect-payment screen's payload. /// /// Returns a success envelope without calling anything when nothing was diff --git a/lib/providers/summary/summary.dart b/lib/providers/summary/summary.dart index b6d3120..ff92c33 100644 --- a/lib/providers/summary/summary.dart +++ b/lib/providers/summary/summary.dart @@ -8,8 +8,7 @@ import 'package:flutter/foundation.dart'; class SummaryProvider { final String baseUrl = ApiConstants.summaryApiLive; - Future fetchSummaryStats(int userId) => - _fetchSummaryStatsNew(); + Future fetchSummaryStats(int userId) => _fetchSummaryStatsNew(); /// NEW API: GET /miler/earnings?period=daily|weekly|monthly /// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }. diff --git a/lib/test/tmp_m/m.dart b/lib/test/tmp_m/m.dart index 90ed150..ea64caf 100644 --- a/lib/test/tmp_m/m.dart +++ b/lib/test/tmp_m/m.dart @@ -15,7 +15,7 @@ Future<({int w, int h})> callout(String text, double dpr) async { style: TextStyle( fontSize: 12 * dpr, height: 1.25, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/utils/device.dart b/lib/utils/device.dart index 43e8643..ba12bdc 100644 --- a/lib/utils/device.dart +++ b/lib/utils/device.dart @@ -1,37 +1,92 @@ +import 'dart:math'; + +import 'package:device_info_plus/device_info_plus.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Identifiers for this handset: one stable device id, one push token. +/// +/// ── Neither of these may ever fail a sign-in ── +/// +/// [ensureDeviceId] used to `throw` on every unhappy path — on an empty +/// hardware id, on a `PlatformException`, and on anything else it caught. It is +/// awaited on the line *before* the PIN check posts, inside the sign-in's outer +/// `try`, so a throw here never reached the network at all: the rider got +/// "Could not reach the server", pressed Retry, and the MPIN screen painted +/// **"Incorrect MPIN"** at him. For every phone number, on every attempt. +/// +/// Two things made that certain rather than unlucky: +/// +/// • It asked for `androidInfo` **unconditionally**. On iOS that call throws, +/// so no iPhone could ever complete a PIN login. +/// • The value is not even sent. `POST /miler/verify-pin` takes `phone`, +/// `pin`, `configid` and `device_token` — `deviceId` is dropped on the floor +/// inside `AuthProvider.login`. The sign-in was being aborted by a field the +/// sign-in does not use. +/// +/// So: this file hands back a best-effort answer and **never throws**. A caller +/// that wants to know whether the answer is real can compare against +/// [unknownDeviceId]; nothing currently needs to. class DeviceUtils { static const String _deviceIdKey = 'deviceId'; static const String _fcmTokenKey = 'fcmToken'; + /// Prefix on ids this class generated rather than read off the hardware. + static const String unknownDeviceId = 'generated-'; + + /// A stable id for this install, persisted on first use. + /// + /// Hardware id where the platform has one — Android's `id`, iOS's + /// `identifierForVendor` — and a persisted random id everywhere else, which + /// is what the backend actually needs: something that does not change + /// between launches. Never throws. static Future ensureDeviceId(SharedPreferences prefs) async { final String? existing = prefs.getString(_deviceIdKey); - if (existing != null && existing.isNotEmpty) { - if (kDebugMode) debugPrint('[DEVICE] Using cached device ID: $existing'); - return existing; - } + if (existing != null && existing.isNotEmpty) return existing; + + String resolved = ''; try { - final deviceInfo = DeviceInfoPlugin(); - final android = await deviceInfo.androidInfo; - final String androidId = android.id; - if (androidId.isNotEmpty) { - await prefs.setString(_deviceIdKey, androidId); - return androidId; - } else { - throw Exception('Android ID is empty'); + final info = DeviceInfoPlugin(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + resolved = (await info.androidInfo).id; + case TargetPlatform.iOS: + resolved = (await info.iosInfo).identifierForVendor ?? ''; + case TargetPlatform.macOS: + resolved = (await info.macOsInfo).systemGUID ?? ''; + case TargetPlatform.windows: + resolved = (await info.windowsInfo).deviceId; + case TargetPlatform.linux: + resolved = (await info.linuxInfo).machineId ?? ''; + case TargetPlatform.fuchsia: + resolved = ''; } - } on PlatformException catch (e) { - throw Exception('Failed to get device ID: ${e.message}'); } catch (e) { - throw Exception('Failed to get device ID: $e'); + // A missing plugin, a denied permission, a simulator with no vendor id. + // None of these are reasons to stop a rider signing in. + debugPrint('[DEVICE] hardware id unavailable, generating one: $e'); } + + if (resolved.trim().isEmpty) resolved = _generateId(); + await prefs.setString(_deviceIdKey, resolved); + return resolved; } + /// 128 bits of hex behind [unknownDeviceId], so a generated id is + /// self-identifying in a log and cannot collide with a hardware one. + static String _generateId() { + final rand = Random.secure(); + final bytes = List.generate(16, (_) => rand.nextInt(256)); + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '$unknownDeviceId$hex'; + } + + /// The FCM registration token, or `''` when push is unavailable. + /// + /// Already total, and stays that way: a rider who declined notifications + /// still has a day's work to do. static Future ensureFcmToken(SharedPreferences prefs) async { try { final String? existing = prefs.getString(_fcmTokenKey); diff --git a/lib/utils/external_navigation.dart b/lib/utils/external_navigation.dart new file mode 100644 index 0000000..df10d6e --- /dev/null +++ b/lib/utils/external_navigation.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Hands a destination to the phone's own maps app. +/// +/// Native intent first, web as the fallback, so a rider gets the same app +/// whichever control in Miler he presses. Three screens had their own copy of +/// these two URLs — a route row, a map screen and a confirmation sheet — which +/// is three chances for one of them to open a different app, or the wrong +/// scheme, on the one action a rider takes at every single stop. +/// +/// Silent on failure by design: there is nothing useful to say to a rider whose +/// phone has no maps application, and the screen he pressed from still has the +/// address on it. +Future openExternalNavigation(double lat, double lng) async { + try { + final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d'); + if (await launchUrl(native, mode: LaunchMode.externalApplication)) return; + } catch (_) { + // Falls through to the web URL below — a device with no navigation intent + // handler is the normal case on iOS, not an error. + } + try { + await launchUrl( + Uri.parse( + 'https://www.google.com/maps/dir/?api=1' + '&destination=$lat,$lng&travelmode=driving', + ), + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[NAV] could not open maps: $e'); + } +} diff --git a/lib/views/Dashboard/activity/activity_format.dart b/lib/views/Dashboard/activity/activity_format.dart new file mode 100644 index 0000000..2082182 --- /dev/null +++ b/lib/views/Dashboard/activity/activity_format.dart @@ -0,0 +1,187 @@ +/// ───────────────────────────────────────────────────────────────────────── +/// HOW A FINISHED STOP IS WRITTEN DOWN +/// +/// The Activity list and the Delivery Details page describe the same records +/// from two distances — one for scanning, one for investigating — and both have +/// to write a clock, a duration, a distance, a rupee figure and a route the +/// same way. A rider who reads `1h 27m` on a row and `1:27` on the page it +/// opens has two facts to reconcile where there is one. +/// +/// So the formatters live here, once, as plain functions. No widgets, no +/// state, no I/O — testable without a widget tree, and impossible to +/// accidentally fork by copying a screen. +/// ───────────────────────────────────────────────────────────────────────── +library; + +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; + +/// First parseable timestamp among [keys], or null. +/// +/// Null is a real answer: a stop closed in bulk from Home never opened the map, +/// so it genuinely has no departure time. Callers drop what they cannot say. +DateTime? stampOf(Map stop, List keys) { + for (final k in keys) { + final parsed = parseStamp(stop[k]); + if (parsed != null) return parsed; + } + return null; +} + +/// One timestamp, read as the wall-clock it actually is. +/// +/// ── The false `Z` ── +/// +/// Doormile timestamps are IST wall-clock in Postgres `timestamp without time +/// zone` columns. Some responses come back with a trailing `Z` anyway — a known +/// Go + pgx footgun where a naive DB timestamp loads into `time.Time` under the +/// UTC location and marshals with a zone marker it never had. The console hit +/// this first and strips it the same way; see `doormileTimestamp.js`. +/// +/// `DateTime.tryParse` believes the `Z`, so `11:43Z` became a real UTC instant +/// — and that broke the tracking rail in the one way it cannot survive: an +/// `Order placed 11:43` landed *below* two 11:57 events, because as an absolute +/// instant it is 17:13 IST. A timeline whose whole meaning is the order of its +/// rungs was rendering them out of order, while each rung's own clock still +/// printed the right digits, so nothing on screen explained why. +/// +/// Stripping the marker before parsing makes both shapes — truly naive, and +/// naive-with-a-false-`Z` — read as the digits the backend meant. A stamp the +/// app wrote itself carries no marker and is unaffected. +DateTime? parseStamp(dynamic raw) { + final s = (raw ?? '').toString().trim(); + if (s.isEmpty) return null; + final stripped = s.replaceFirst(RegExp(r'(Z|[+-]\d{2}:?\d{2})$'), ''); + return DateTime.tryParse(stripped) ?? DateTime.tryParse(s); +} + +/// Best available "when did this happen", epoch-comparable. +/// +/// The app's own stamps first: they are written at the moment the rider acts, +/// so they are both the most accurate and the only ones present on a stop the +/// backend has not caught up with yet. Falls back to zero so a record with no +/// timestamp sinks rather than jumping to the top of the day. +DateTime happenedAt(Map stop) => + stampOf(stop, const [ + 'completedat', + 'skippedat', + 'pickedtime', + 'picked_time', + 'deliverytime', + 'updatedon', + 'modifiedon', + 'expected_pickup_time', + ]) ?? + DateTime.fromMillisecondsSinceEpoch(0); + +/// `14:05` → `2:05 PM`. The same shape as the clock the map sheet's ETA prints, +/// so a time read on the way to a stop and the time recorded against it +/// afterwards are written the same way. +String clockOf(DateTime t) { + final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12; + final minute = t.minute.toString().padLeft(2, '0'); + return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}'; +} + +/// A duration at a glance: `6m`, `1h 12m`. +/// +/// Seconds are never shown — nothing on these screens is decided to the second, +/// and they only make the figure harder to compare against the one on the row +/// below it. +String shortDuration(Duration d) { + final total = d.inMinutes; + if (total < 1) return '<1m'; + final hours = total ~/ 60; + final minutes = total % 60; + if (hours == 0) return '${minutes}m'; + return minutes == 0 ? '${hours}h' : '${hours}h ${minutes}m'; +} + +/// `4.6`, `12`. One decimal under ten kilometres and none over it: the second +/// digit stops meaning anything at that distance and costs width on a row. +String kmText(double v) => + v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1); + +/// `₹1,240`. +/// +/// Grouped the Indian way — the last three digits, then twos — because that is +/// how the figure is read back to a hub clerk, and an ungrouped `₹112450` is +/// the one number on the page a rider has to count. +String rupees(double v) { + final whole = v.truncate(); + final fraction = v - whole; + final digits = whole.abs().toString(); + + String grouped; + if (digits.length <= 3) { + grouped = digits; + } else { + final last3 = digits.substring(digits.length - 3); + var rest = digits.substring(0, digits.length - 3); + final parts = []; + while (rest.length > 2) { + parts.insert(0, rest.substring(rest.length - 2)); + rest = rest.substring(0, rest.length - 2); + } + if (rest.isNotEmpty) parts.insert(0, rest); + grouped = '${parts.join(',')},$last3'; + } + + final sign = v < 0 ? '-' : ''; + final tail = fraction == 0 ? '' : '.${(fraction * 10).round().clamp(0, 9)}'; + return '$sign₹$grouped$tail'; +} + +/// The ETA the hub gave for this stop — minutes allowed, or the clock time it +/// was due by, whichever the booking carries. Null when it carries neither. +String? plannedEtaOf(Map stop) { + final minutes = int.tryParse((stop['eta'] ?? '').toString().trim()); + if (minutes != null && minutes > 0) return '$minutes min allowed'; + + final due = stampOf(stop, const [ + 'expected_pickup_time', + 'expectedpickuptime', + 'slotendtime', + ]); + return due == null ? null : 'By ${clockOf(due)}'; +} + +/// The two ends of a stop's journey, or one end when the payload names only +/// one. +/// +/// Which name belongs at which end is a question about the **line**, and +/// getting it wrong reads as a rider having ridden the route backwards: +/// +/// • **A round** collects at a source and hands over at a door, so the kitchen +/// leads and the subscriber is the destination. +/// • **A parcel booking** is a first-mile collection *from* the customer, and +/// where it goes afterwards is the hub's problem — there is no second name +/// to print, so the customer stands alone rather than getting an arrow to +/// nowhere. +/// +/// Read from [MilkRun] and [ServiceProfile] so this and the delivery card +/// cannot describe one stop as two different journeys. +({String from, String? to}) routeEndsOf(Map stop) { + final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? '') + .toString() + .trim(); + final source = MilkRun.sourceNameOf(stop); + if (ServiceProfile.active.deliversToCustomer && + source.isNotEmpty && + customer.isNotEmpty) { + return (from: source, to: customer); + } + return (from: customer.isEmpty ? 'Stop' : customer, to: null); +} + +/// `Vidhya Kitchen to Joe Mathew`, or the bare customer. See [routeEndsOf]. +/// +/// **For semantics and logs, not for drawing.** The arrow between the two ends +/// is a widget on screen — Poppins carries no U+2192 and the character rendered +/// as a tofu box on the one line of the record a rider reads first. A screen +/// reader wants the word anyway: "Vidhya Kitchen right-arrow Joe Mathew" is not +/// a sentence. +String routeLineOf(Map stop) { + final ends = routeEndsOf(stop); + return ends.to == null ? ends.from : '${ends.from} to ${ends.to}'; +} diff --git a/lib/views/Dashboard/activity/activity_page.dart b/lib/views/Dashboard/activity/activity_page.dart index 9608980..17589cf 100644 --- a/lib/views/Dashboard/activity/activity_page.dart +++ b/lib/views/Dashboard/activity/activity_page.dart @@ -1,23 +1,34 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:lottie/lottie.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/Models/stop_status.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/data/stop_area.dart'; import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/work_scope.dart'; +import 'package:miler/data/order_events.dart'; +import 'package:miler/data/milk_run.dart'; import 'package:miler/data/stop_compliance.dart'; +import 'package:miler/views/Dashboard/activity/activity_format.dart'; +import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; +import 'package:miler/views/helpers/widgets/page_transitions.dart'; import 'package:miler/providers/pickup/pickup_provider.dart'; -import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/pickups/pickups.dart'; -import 'package:miler/views/Dashboard/home/trip_tabs.dart'; -import 'package:miler/views/helpers/constants/apiconstants.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; -import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/data/service_day.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:miler/widget/Bottom_page.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -62,6 +73,51 @@ import 'package:miler/widget/Bottom_page.dart'; /// it that is not finished: a skipped stop is a promise to go back, and the /// rider has until the end of the shift to keep it. /// ───────────────────────────────────────────────────────────────────────── +/// The slices of a day the rider ever asks for. +/// +/// Four, because four is what fits on a phone without scrolling and because +/// every one of them has a real predicate over data the page already holds — +/// there is no chip here standing in for a query the app cannot answer. +/// +/// **Active** is the parked work: stops he walked away from and still owes a +/// return visit. It is the only slice on this page carrying an action, which is +/// why it sits second, next to All, rather than at the end with the settled +/// ones. +enum _ActivityFilter { + all('All', LucideIcons.layers, 'Nothing here yet.'), + active( + 'Active', + LucideIcons.circleSlash, + 'Nothing waiting on a return visit.', + ), + completed('Completed', LucideIcons.packageCheck, 'Nothing finished yet.'), + cancelled('Cancelled', LucideIcons.ban, 'Nothing was cancelled.'); + + const _ActivityFilter(this.label, this.glyph, this.emptyLine); + + final String label; + final IconData glyph; + + /// What the page says when this slice is empty but the day is not. Phrased as + /// a fact about the slice, never as an apology. + final String emptyLine; + + /// Whether a row belongs in this slice. + /// + /// The four are exhaustive and disjoint over what [_ActivityPageState._all] + /// can hold: a stop is parked, cancelled/declined, or settled. + bool admits(Map stop) { + final s = stopStatusOf(stop); + return switch (this) { + _ActivityFilter.all => true, + _ActivityFilter.active => s.isSkipped, + _ActivityFilter.cancelled => s.isCancelled || s.isRejected, + _ActivityFilter.completed => + s.isFinishedPickup && !s.isCancelled && !s.isRejected, + }; + } +} + class ActivityPage extends StatefulWidget { const ActivityPage({super.key}); @@ -82,11 +138,10 @@ class _ActivityPageState extends State { /// Order ids with a resume in flight, so one cannot be fired twice. final Set _busyIds = {}; - /// Rows the rider has opened. - /// - /// Held by order id rather than list index: the list re-sorts on every poll, - /// and an index would quietly move the open row onto a different stop. - final Set _openIds = {}; + /// Which slice of the day is on screen. Held on the state, not derived: it is + /// the rider's own choice and it must survive a poll, a pull-to-refresh and + /// the tab going away and coming back. + _ActivityFilter _filter = _ActivityFilter.all; bool _firstLoadDone = false; bool _failed = false; @@ -95,44 +150,11 @@ class _ActivityPageState extends State { /// Which trip tab is open. Held across refreshes; clamped at read time, so a /// trip disappearing between polls cannot leave the page pointing past the /// end of the list. - int _selectedTripIndex = 0; /// Everything this page accounts for: what he put down, then what he /// finished. One list so the trip grouping sees the whole run. List> get _all => [..._skipped, ..._done]; - /// The day's stops, grouped into the same trips Home groups them into. - /// - /// The rider's day is Trip 1 / 2 / 3 everywhere else in the app; a flat list - /// of everything he finished would be the one screen that made him work out - /// for himself which run a stop belonged to. Same grouping function as Home, - /// so the two can never disagree about where a stop sits. - List get _trips => Trip.groupIntoTrips(_all); - - /// Stops on the selected tab. Empty — not a fallback to another trip — when - /// the tab is one of the slots [TripTabs] always draws for a day the hub has - /// not filled: showing Trip 1's stops under a "Trip 3" label would be a lie - /// about which run the rider is looking at. - List> get _visible { - final trips = _trips; - if (_selectedTripIndex < 0 || _selectedTripIndex >= trips.length) { - return const []; - } - return trips[_selectedTripIndex].stops; - } - - /// The selected trip's rows, split the way the page draws them. - (List> skipped, List> done) - get _visibleSplit { - final stops = _visible; - return ( - stops.where((s) => stopStatusOf(s).isSkipped).toList() - ..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))), - stops.where((s) => stopStatusOf(s).isFinishedPickup).toList() - ..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))), - ); - } - @override void initState() { super.initState(); @@ -198,11 +220,29 @@ class _ActivityPageState extends State { // So the completed store is merged in last and overwrites: it is the // newest thing the app knows, it carries the completion time the API does // not, and it is what the Bookings tab acted on when it dropped the stop. + // + // (The store is scoped to this rider/tenant/line at its key — see + // [WorkScope] — so what comes back is already only this session's.) for (final row in await getCompletedBookings()) { final key = _keyOf(row); merged[key] = {...?merged[key], ...row}; } + // ── The rider's own half of the history ── + // + // Accepted, arrived, picked, set off: four moments the backend keeps no + // event feed for, stamped locally as they happened. Merged onto the row + // so the details page reads one object rather than joining two stores + // itself. Absent keys stay absent — a moment with no stamp is not drawn. + // See [OrderEvent]. + for (final row in merged.values) { + final id = (row['orderid'] ?? '').toString(); + if (id.isEmpty) continue; + final events = await getOrderEvents(id); + if (events.isNotEmpty) row.addAll(events); + } + unawaited(pruneOrderEvents()); + // Skips last of all, and for the same reason: the store is the newest // thing the app knows about one, it carries the reason and the time the // API has no field for, and on demo data it is the *only* record. A stop @@ -211,8 +251,55 @@ class _ActivityPageState extends State { final skippedStore = await getSkippedBookings(); for (final row in skippedStore) { final key = _keyOf(row); - merged[key] = {...?merged[key], ...row}; + // ── Which skips can actually be taken back ── + // + // Only these. A skip parked from the *pickup* leg is resumable: the + // stop goes back to accepted and the rider drives to it again. + // + // A skip taken at a customer's door is not. `resumeSkippedStop` posts + // an **accept** against the booking, which is the wrong object and the + // wrong rung for a parcel that has already been collected and + // converted — and the delivery flow has, by then, dropped the stop from + // the rider's hands and forgotten its consignment mapping. Offering + // Resume there is a button that cannot do what it says. The record is + // still shown, with its reason; what it does not carry is a promise. + merged[key] = {...?merged[key], ...row, 'canresume': true}; } + // ── The ownership rule, applied once, at the data boundary ── + // + // Everything above is merged from four sources; this is the single gate + // they all pass through before Activity renders anything. A row that + // proves it belongs to another rider, tenant or line is dropped — + // identity only, never a display string like a kitchen name or the word + // "Milk". Rows carrying no identity are kept: the API returned them for + // *this* authenticated session, and the local stores are already keyed + // to it. See [WorkScope]. + final scope = await WorkScope.current(); + merged.removeWhere((_, row) => scope.excludes(row)); + + // ── Today's shift, and only today's ── + // + // Activity is an operational log, not an archive: what it answers is + // "how has today gone". The local stores already prune themselves to the + // current day, but the API half does not — `GET /miler/bookings` returns + // the rider's whole open set, so a booking finished yesterday and still + // listed came through and sat among this morning's work. + // + // Filtered at the boundary rather than in the widget, so every count, + // every trip summary and the day's cash total are all computed off the + // same set the list draws. Nothing is deleted — see [ServiceDay] for why + // this is a read-time question and not a scheduled purge. + final serviceDay = ServiceDay.today; + final before = merged.length; + merged.removeWhere( + (_, row) => !ServiceDay.belongsToToday(row, now: serviceDay), + ); + if (before != merged.length) { + debugPrint( + '[ACTIVITY] $serviceDay — kept ${merged.length} of $before rows', + ); + } + final skippedIds = skippedStore .map((r) => (r['orderid'] ?? '').toString()) .toSet(); @@ -268,408 +355,1431 @@ class _ActivityPageState extends State { return id.isNotEmpty ? id : (o['pickupid'] ?? o.hashCode).toString(); } - /// Best available "when did this happen" time, epoch-comparable. Falls back - /// to zero so a record with no timestamp sinks rather than jumping to the top. - DateTime _happenedAt(Map o) { - for (final key in const [ - // The app's own stamps first: they are written at the moment the rider - // acts, so they are both the most accurate and the only ones present on - // a stop the backend has not caught up with yet. - 'completedat', - 'skippedat', - 'pickedtime', - 'picked_time', - 'deliverytime', - 'updatedon', - 'modifiedon', - 'expected_pickup_time', - ]) { - final raw = (o[key] ?? '').toString().trim(); - if (raw.isEmpty) continue; - final parsed = DateTime.tryParse(raw); - if (parsed != null) return parsed; - } - return DateTime.fromMillisecondsSinceEpoch(0); - } + /// Best available "when did this happen" time, epoch-comparable. + /// + /// One implementation, shared with the details page — [happenedAt] reads the + /// same key order and goes through [parseStamp], which is what makes a + /// backend timestamp carrying a false `Z` sort where its own printed clock + /// says it should. This used to be a second copy with a bare + /// `DateTime.tryParse` in it, and the two disagreed by five and a half hours + /// on exactly the rows the backend had touched. + DateTime _happenedAt(Map o) => happenedAt(o); @override Widget build(BuildContext context) { + final bool hasWork = _all.isNotEmpty; + return Scaffold( - backgroundColor: ColorConstants.pureSurface, + // The brand shows through the sheet's two top corners — see [MilerSheet]. + backgroundColor: ColorConstants.primary, appBar: const MilerAppBar(title: 'Activity'), - body: RefreshIndicator( - onRefresh: _load, - color: ColorConstants.primary, - // ── The trip bar is furniture, not content ── - // - // It used to live inside the populated branch, so a rider who had - // finished nothing yet saw a bare empty state and asked where his - // Trip 1 / 2 / 3 had gone. The bar answers "which run am I looking at", - // which is a question worth answering on an empty morning too — Home - // draws all three slots before the hub has assigned any of them, for - // exactly the same reason. So it sits above the swap, and only what is - // *under* it changes. - child: Column( - children: [ - if (_firstLoadDone) - TripTabs( - trips: _trips, - selectedIndex: _selectedTripIndex, - onSelect: (i) => setState(() => _selectedTripIndex = i), + body: MilerSheet( + child: RefreshIndicator( + onRefresh: _load, + color: ColorConstants.primary, + // ── The chrome is pinned; only the answer under it swaps ── + // + // The strapline and the filter bar are the page's own furniture: they + // say what this screen is and which slice of it he is looking at, and a + // filter that scrolled away would leave the rider reading a partial + // list with nothing on screen explaining why. They are hidden entirely + // on a day with no work, because there is nothing to filter and an + // empty state should own the screen it is explaining. + // ── Stretch, or the furniture is centred instead of guttered ── + // + // A `Column` cross-aligns `center` by default, so this one handed + // `_chrome` loose width: it shrink-wrapped to its widest child and + // was then centred. The heading and the filter bar therefore sat on + // whatever edge the centring happened to leave — measured at 48pt — + // while the trip rows below stood correctly on the 20pt gutter. + // + // Every `_gutter` inside the chrome was being applied to a box that + // was not the width of the page, which is why the numbers in the + // code and the pixels on the screen disagreed. + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_firstLoadDone && hasWork) _chrome(), + Expanded( + child: SmoothSwap( + child: !_firstLoadDone + ? _skeleton() + : _failed && _all.isEmpty + ? ErrorRetry( + key: const ValueKey('activity-error'), + onRetry: _load, + ) + : !hasWork + ? _empty() + : _list(), + ), ), - Expanded( - child: SmoothSwap( - child: !_firstLoadDone - ? const SkeletonList(key: ValueKey('activity-loading')) - : _failed && _all.isEmpty - ? ErrorRetry( - key: const ValueKey('activity-error'), - onRetry: _load, - ) - : _visible.isEmpty - ? _empty() - : _list(), - ), - ), - ], + ], + ), ), ), ); } - /// Nothing finished on the *selected* trip, which is a different sentence - /// depending on whether the day is empty or just this tab is. - /// - /// The tinted-halo icon the shared [MilerEmptyState] draws is fine for an - /// error or a filter with no matches; on the first screen of a tab it is a - /// grey glyph on a grey page. The animation says the same thing with some - /// warmth, and Bookings already greets an empty queue this way — a rider - /// switching tabs should meet the same kind of screen, not two conventions. - Widget _empty() { - final dayEmpty = _all.isEmpty; - return ListView( - key: const ValueKey('activity-empty'), - // A scrollable, so the pull-to-refresh still works on an empty day. - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox(height: 24.h), - // One empty-state component app-wide — see [MilerEmptyState]. The copy - // stays screen-specific, because "nothing yet today" and "you have not - // worked this trip" are different sentences; only the chrome unifies. - MilerEmptyState( - icon: Icons.history_rounded, - illustration: Lottie.asset( - // "Nothing yet" on the whole day vs. a trip he simply has not - // worked: the parcel-in-motion loop reads as waiting, the tick as a - // run that is genuinely clear. - dayEmpty - ? 'assets/lotties/no_bookings.json' - : 'assets/lotties/all_caught_up.json', - fit: BoxFit.contain, - repeat: true, - // A missing or corrupt animation must not take the tab down with - // it — an empty state is the one screen that has to render. - errorBuilder: (_, __, ___) => Icon( - Icons.history_rounded, - size: 64.sp, - color: ColorConstants.borderStrong, - ), - ), - title: dayEmpty - ? 'Nothing finished yet' - : 'Nothing finished on this trip', - message: dayEmpty - ? 'Stops you complete today land here, newest first — with ' - 'whether you made the ETA and stayed on route.' - : 'Stops you complete on it will be listed here.', - accent: ColorConstants.primary, - actionLabel: dayEmpty ? 'Go to Bookings' : null, - onAction: dayEmpty ? () => BottomPage.goToTab(1) : null, - ), - ], - ); - } + // ── The shift record, as a page ─────────────────────────────────────────── + // + // Activity ← app bar + // Your recent delivery activity ← what this screen is + // [ All ] Active Completed Cancelled ← which slice of it + // ───────────────────────────────────────── + // 4 of 5 on time · ₹2,450 collected ← how the day scored + // TODAY + // ● Delivered 11:42 AM + // Vidhya Kitchen → Joe Mathew + // 2 orders · 4.6 km · 1h 27m + // YESTERDAY + // … + // + // Nothing here is a card. A card is a container you put round a thing that + // needs separating from the things next to it, and every row on this page is + // the same kind of thing arriving in one order — which is a list, and a list + // is separated by rhythm. The only surface on the whole screen is the paper. - /// The trip, in the order the rider needs it: what still owes him a return - /// visit, then what is settled. - /// - /// ── One list, not a stack of cards ── - /// - /// Every finished stop used to be a filled, rounded card with 12pt of air - /// under it, and inside each one a *second*, white panel holding the figures. - /// A day's work therefore came down the screen as a column of grey slabs with - /// white slabs punched into them — the card-inside-card shape, repeated once - /// per stop. - /// - /// Nothing about a finished stop needs a container. They are all the same kind - /// of thing, arriving in one order, read by scanning down: that is a list, and - /// a list is separated by rules. The rows are on the page now, with a hairline - /// between them, which also lets the figure columns line up with the ones in - /// the row above — the comparison the strip was built for and never quite - /// delivered while every row sat in its own box. - Widget _list() { - final (skipped, done) = _visibleSplit; - final cash = done.fold(0, (s, o) => s + stopCollectionAmount(o)); + /// The page's single left edge. Every row, every heading and every figure + /// column starts here, which is what lets the eye run straight down a day's + /// work instead of stepping in and out of a card's padding on every stop. + static double get _gutter => 20.w; - return ListView( - key: const ValueKey('activity-list'), - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - padding: EdgeInsets.fromLTRB( - 20.w, - 4.h, - 20.w, - BottomPage.bottomInset(context) + 12.h, - ), - children: [ - _daySummary(cash, done.length, ComplianceSummary.from(done)), - if (skipped.isNotEmpty) ...[ - _groupHeader( - icon: Icons.replay_rounded, - label: - 'Go back to ${skipped.length == 1 ? 'this stop' : 'these stops'}', - accent: ColorConstants.warning, - count: skipped.length, - ), - for (final stop in skipped) _skippedRow(stop), - ], - if (done.isNotEmpty) ...[ - if (skipped.isNotEmpty) - _groupHeader( - icon: Icons.check_circle_outline_rounded, - label: 'Finished', - accent: ColorConstants.acceptGreen, - count: done.length, - ), - // The rule goes *between* rows, so the list has no trailing edge - // hanging under its last stop. - for (final (i, stop) in done.indexed) ...[ - if (i > 0) - Container(height: 1, color: ColorConstants.borderSubtle), - _row(stop), - ], - ], - ], - ); - } + /// The padding a filter chip carries inside itself. Named because the bar + /// has to subtract exactly this much to put its first label on [_gutter]. + static double get _chipInset => 8.w; - /// A quiet rule-and-label between the two kinds of row. Only drawn when both - /// kinds are present — a single group needs no heading to tell it apart from - /// the other one. - Widget _groupHeader({ - required IconData icon, - required String label, - required Color accent, - required int count, - }) { + // ══════════════════════════════════════════════════════════════════════ + // CHROME — what this screen is, and which slice of it + // ══════════════════════════════════════════════════════════════════════ + + Widget _chrome() { + // ── The gutter is a line, not a padding value ── + // + // Padding this whole column put the heading's text on the gutter but the + // first tab's *label* eight points right of it, because the chip carries + // its own padding inside. Two left edges, eight apart, one under the other + // — close enough to look like a mistake rather than a margin. + // + // So the column is unpadded and each child lands its own ink on the line: + // the overview by plain padding, the bar by subtracting the padding its + // chips already have. The chips keep their full tap width. return Padding( - padding: EdgeInsets.only(top: 4.h, bottom: 10.h), - child: Row( - children: [ - Icon(icon, size: 15.sp, color: accent), - SizedBox(width: 7.w), - Text( - label.toUpperCase(), - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.6, - color: accent, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(width: 8.w), - Expanded( - child: Divider(height: 1, color: ColorConstants.dividerColor), - ), - SizedBox(width: 8.w), - Text( - '$count', - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w800, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ); - } - - /// One parked stop: why it was put down, when, and the single way back. - /// - /// Amber rather than red, and worded as a return visit rather than a failure. - /// A skip is a legitimate call at a closed shutter — the rider is expected to - /// make it and expected to come back — and colouring it like an error would - /// teach him to wait at doors instead, which costs the whole route. - Widget _skippedRow(Map stop) { - final accent = ColorConstants.warning; - final kind = stopKindOf(stop); - final id = (stop['orderid'] ?? '').toString(); - final busy = _busyIds.contains(id); - final name = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop') - .toString(); - final address = (stop['pickupaddress'] ?? stop['PickupAddress'] ?? '') - .toString() - .trim(); - final reason = (stop['skipreason'] ?? stop['notes'] ?? '') - .toString() - .trim(); - final at = _happenedAt(stop); - final hasTime = at.millisecondsSinceEpoch > 0; - - return Container( - margin: EdgeInsets.only(bottom: 10.h), - decoration: BoxDecoration( - // Amber-tinted rather than outlined: the wash is what separates a stop - // still owed a visit from the finished ones under it. - color: accent.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), + padding: EdgeInsets.symmetric(vertical: 2.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 0), + padding: EdgeInsets.symmetric(horizontal: _gutter), + child: _shiftOverview(), + ), + SizedBox(height: 16.h), + _filterBar(), + ], + ), + ); + } + + /// ── How the shift has gone, in three lines ── + /// + /// The strapline said *Your recent delivery activity*, under an app bar that + /// said **Activity**. One of those two was spending a line to repeat the + /// other, and neither answered the question a rider opens this screen with + /// between stops: **how am I doing today**. + /// + /// Typography, not statistic cards. Boxed figures are for a dashboard — + /// something you compare across days, at a desk. This is a shift log read + /// one-handed at a gate, and three lines of plain text are faster to take in + /// than three bordered cells and cost no vertical space to draw. + /// + /// **Only what the data already holds.** Completed and needing-attention are + /// counts of the rows on screen; cash comes from the same figures the stops + /// carry; on-time comes from [ComplianceSummary], which is what the day + /// footer already computed. Nothing is derived that the record cannot prove, + /// and a metric with nothing behind it is not printed at all — a shift with + /// no measured stops has no on-time line rather than a confident `0%`. + Widget _shiftOverview() { + final rows = _all; + final done = rows.where((s) => stopStatusOf(s).isFinishedPickup).toList(); + final attention = rows.where((s) => stopStatusOf(s).isSkipped).length; + final cash = done.fold(0, (s, o) => s + stopCollectionAmount(o)); + final c = ComplianceSummary.from(done); + + // Each half carries its own colour, so the pair is built as (text, owed?) + // rather than as one string — see the header below for why. + // The completed count is the numeral above now, so this line carries + // only what the numeral cannot: the exception. + final second = <(String, bool)>[ + if (attention > 0) + ('$attention ${attention == 1 ? 'needs' : 'need'} attention', true), + ]; + final third = [ + if (ServiceProfile.active.collectsCash && cash > 0) + '${rupees(cash)} collected', + if (c.measured > 0) '${((c.onTime / c.measured) * 100).round()}% on time', + ]; + + // ── The day's figure leads, the way Home's does ── + // + // "Today" at 19sp over a grey sentence was a heading over a caption — + // nothing on the page was the *result*. The completed count is the fact a + // rider opens this screen for, so it takes the numeral treatment the app + // now uses everywhere a figure is the headline: big, tabular, captioned + // small. "Today" steps up to being the eyebrow. With nothing finished yet + // the numeral is withheld — a page that opens with a giant 0 tells a + // rider he has done nothing, which is Home's own lead-with-remainder rule + // read from the other end. + // No `TODAY` eyebrow — this page has a standing rule (and a test) that + // the all-caps date-band vocabulary never returns; the word rides in the + // numeral's caption instead, lowercase, where it is a fact and not a + // band. + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (done.isNotEmpty) ...[ + SizedBox(height: 2.h), + MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + Text( + '${done.length}', + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w800, + letterSpacing: -1, + height: 1.0, + fontFeatures: const [FontFeature.tabularFigures()], + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 7.w), + Flexible( + child: Padding( + padding: EdgeInsets.only(top: 6.h), + child: Text( + '${ServiceProfile.active.completionVerb.toLowerCase()}' + ' today', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ), + ] else ...[ + SizedBox(height: 4.h), + Text( + 'Today', + style: MilerType.body.copyWith( + fontSize: 19.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + ], + if (second.isNotEmpty) ...[ + SizedBox(height: 4.h), + // ── Amber is a word, not a line ── + // + // This used to colour the joined string, so one skipped stop turned + // "4 delivered" amber too — the shift's good news rendered in the + // warning ink. A rider glancing at it read the whole day as a + // problem. + // + // Colouring per segment is what the rule always meant: amber marks + // the clause that is genuinely owed, and everything beside it stays + // neutral. With nothing owed the line has no colour at all, which is + // the point — amber has to be rare to still mean something. + Text.rich( + TextSpan( + children: [ + for (var i = 0; i < second.length; i++) ...[ + if (i > 0) + TextSpan( + text: ' · ', + style: TextStyle(color: ColorConstants.secondaryText), + ), + TextSpan( + text: second[i].$1, + style: TextStyle( + color: second[i].$2 + ? ColorConstants.warning + : ColorConstants.secondaryText, + ), + ), + ], + ], + ), + style: MilerType.body.copyWith( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + ), + ), + ], + if (third.isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + third.join(' · '), + style: MilerType.micro.copyWith(fontSize: 13.sp), + ), + ], + ], + ); + } + + /// ── One filled chip, three words ── + /// + /// The selected filter is the only one wearing a container. That is the whole + /// design: a bar where every chip is outlined is four boxes competing to say + /// which one is on, and on a page whose brief is *containers recede* it would + /// be the loudest object on the screen. Filled-vs-nothing is unambiguous at + /// arm's length, in sunlight, and costs one container instead of four. + /// + /// No counts. A number beside each word doubles the text in the bar to answer + /// a question the list underneath answers better by simply being that long. + /// + /// Scrollable rather than wrapped: at large text scales four chips do not fit + /// a phone's width, and a bar that reflows to two lines moves the list under + /// the rider's thumb. + /// Stops still owing the rider a return visit. Drives the one count in the + /// filter bar, and computed from the same rows the list draws. + int get _attentionCount => + _all.where((s) => stopStatusOf(s).isSkipped).length; + + Widget _filterBar() { + // ── Sized by its own type, never by a fixed box ── + // + // A `SizedBox(height:)` around a row of words clips them the moment the + // rider turns his text size up, and this app is read at 2.0x on a bike. The + // bar takes its height from the label plus symmetric padding, and the + // scroll view is told to shrink-wrap that rather than to fill a number. + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + // `_chipInset` back off the leading edge so the first label — not the + // chip box around it — sits on the same line as the heading above. + padding: EdgeInsets.only( + left: _gutter - _chipInset, + right: _gutter - _chipInset, + ), + physics: const ClampingScrollPhysics(), + child: Row( + children: [ + for (final (i, f) in _ActivityFilter.values.indexed) ...[ + if (i > 0) SizedBox(width: _chipInset), + Builder( + builder: (context) { + final selected = f == _filter; + return Semantics( + button: true, + selected: selected, + label: f.label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: selected ? null : () => setState(() => _filter = f), + child: Padding( + // Horizontal padding only from the gap between words; + // the vertical keeps a 48dp target under a 13.5sp label. + padding: EdgeInsets.symmetric( + horizontal: _chipInset, + vertical: 12.h, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + f.label, + maxLines: 1, + style: MilerType.body.copyWith( + fontSize: 14.sp, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + letterSpacing: -0.1, + color: selected + ? ColorConstants.primary + : ColorConstants.secondaryText, + ), + ), + // ── A count only where it changes a decision ── + // + // On *Active* it does: it is the number of stops + // the rider still owes somebody, and seeing it + // without opening the slice is the reason to + // open the slice. Everywhere else the list's own + // length answers better, and a number beside + // every word doubles the text in the bar. + if (f == _ActivityFilter.active && + _attentionCount > 0) ...[ + SizedBox(width: 5.w), + Text( + '$_attentionCount', + style: MilerType.body.copyWith( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.warning, + ), + ), + ], + ], + ), + SizedBox(height: 6.h), + // The whole selected state: a 2pt brand rule. Four + // pills were four containers arguing about which one + // was on, on a page whose brief is that containers + // recede. + AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + height: 2.h, + width: selected ? 18.w : 0, + decoration: BoxDecoration( + // Transparent when it is not the selected one: + // a zero-width rule still *has* a colour, and a + // brand-coloured box nobody can see is the kind + // of thing that reads as four selected filters + // to anything inspecting the tree. + color: selected + ? ColorConstants.primary + : Colors.transparent, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ], + ), + ); + } + + // ══════════════════════════════════════════════════════════════════════ + // THE LIST + // ══════════════════════════════════════════════════════════════════════ + + /// Rows the selected filter admits, newest first. + /// + /// Sorted once here rather than per section: the date headers are cut out of + /// this order, so the order is the thing that has to be right. + List> get _filtered { + final rows = _all.where(_filter.admits).toList() + ..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))); + return rows; + } + + /// Which trip is open. Null means all collapsed, which is where the page + /// starts on a day with more than one trip. + int? _openTrip; + + /// ───────────────────────────────────────────────────────────────────── + /// TODAY, IN THREE TRIPS + /// + /// A rider's day is not a stream of stops; it is one to three runs, and the + /// question he actually asks this screen is "how did Trip 2 go". The list + /// answered a different one — everything he has ever finished, newest first, + /// cut into date bands — which on a three-run day is forty rows he has to + /// scroll to reconstruct a grouping the hub already assigned him. + /// + /// So the grouping is the hub's, not the clock's: [Trip.groupIntoTrips] + /// buckets on `tripid`/`slotid` when the backend sends one and on the day + /// part only when it does not, and [Trip.sortStops] holds the admin sequence + /// inside each. **Nothing here reorders a route** — this screen shows how a + /// planned run went, so the order it shows has to be the planned order. + /// + /// One trip open at a time. Three collapsed headers and one expanded body is + /// a screen the rider can hold in his head; three expanded bodies is the + /// list he already could not read. + /// ───────────────────────────────────────────────────────────────────── + Widget _list() { + final rows = _filtered; + if (rows.isEmpty) return _noneInFilter(); + + // The day's score is the *finished* work's score — a parked stop has not + // been measured against anything yet — so it is computed off the settled + // rows regardless of which slice is on screen. + // ── The day's figures moved to the top of the page ── + // + // They used to head the list: on-time, on-route and cash, as a dotted line + // above the first stop. That is the same question `_shiftOverview` now + // answers in the chrome, where it belongs — a summary of the shift is not + // the first item *of* the shift, and printing it twice left a band of dead + // space between the filter and the first trip. + final children = []; + + final slots = Trip.groupIntoTrips(rows).slots; + final live = [ + for (var i = 0; i < slots.length; i++) + if (slots[i] != null && slots[i]!.stops.isNotEmpty) (i, slots[i]!), + ]; + + // A day that produced one run needs no accordion: a single header the + // rider must tap to see the only thing on the page is a lock on an empty + // door. It opens as a plain list under its own heading. + if (live.length <= 1) { + if (live.isEmpty) return _listView(children); + final (slot, trip) = live.single; + children.add(_tripHeader(slot, trip, expanded: true, tappable: false)); + children.addAll(_tripBody(trip)); + return _listView(children); + } + + for (final (slot, trip) in live) { + final open = _openTrip == slot; + children.add(_tripHeader(slot, trip, expanded: open, tappable: true)); + children.add( + // AnimatedSize over an if/else: the body has to *reveal*, and a + // subtree that appears at full height reads as the page jumping rather + // than as a drawer opening. Curve and duration are the app's own — + // see [DesignConstants.motionState]. + ClipRect( + child: AnimatedSize( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: open + // The same well Home's kitchens open into: a canvas-toned + // inset that makes the open trip read as *contents*, not as + // more page. One system, both accordions. + ? Container( + margin: EdgeInsets.only(bottom: 6.h), + padding: EdgeInsets.fromLTRB(10.w, 6.h, 10.w, 0), + decoration: BoxDecoration( + color: MilerSurface.canvas, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _tripBody(trip), + ), + ) + : SizedBox(width: double.infinity, height: 0), + ), + ), + ); + } + + return _listView(children); + } + + /// The stops of one trip, in the order the hub assigned them. + List _tripBody(Trip trip) => [ + for (final (i, stop) in trip.stops.indexed) + _row(stop, first: i == 0, last: i == trip.stops.length - 1), + SizedBox(height: 10.h), + ]; + + /// `Trip 2 · 6 delivered · 1 skipped`, and the chevron that opens it. + /// + /// The summary is the point of a collapsed header: it has to answer "did + /// that run go well" without being opened, or the rider opens all three + /// anyway and the accordion has bought nothing. + Widget _tripHeader( + int slot, + Trip trip, { + required bool expanded, + required bool tappable, + }) { + final finished = trip.stops + .where((s) => stopStatusOf(s).isFinishedPickup) + .toList(); + // ── A logistics day has two kinds of finished stop ── + // + // Collections and deliveries both land in this list, because a line that + // ends at a hub counts `picked` as done. Summing them under one verb told + // the rider "6 picked up" for a run that was three collections and three + // hand-overs — the two halves of his day flattened into whichever word the + // profile happened to name. + final collected = finished + .where((s) => !MilkRun.workingKind(s).isDelivery) + .length; + final handedOver = finished.length - collected; + final skipped = trip.stops.where((s) => stopStatusOf(s).isSkipped).length; + final clean = skipped == 0; + + return Padding( + padding: EdgeInsets.only(top: 14.h, bottom: 4.h), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: tappable + ? () => setState(() => _openTrip = expanded ? null : slot) + : null, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Padding( + // Vertical only. A horizontal inset here bought nothing — the row + // is already full-width, so it added no tap area — and cost the + // disc its place on the gutter the headings stand on. + padding: EdgeInsets.symmetric(vertical: 10.h), + child: Row( + children: [ + // A settled run reads green at a glance; one with a stop still + // owed reads amber. The disc is the only colour on the header, + // so it carries the verdict on its own. Container( width: 34.w, height: 34.w, alignment: Alignment.center, decoration: BoxDecoration( - color: accent.withValues(alpha: 0.12), + color: + (clean + ? ColorConstants.acceptGreen + : ColorConstants.warning) + .withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: Icon(kind.icon, size: 17.sp, color: accent), + child: Icon( + clean ? LucideIcons.check : LucideIcons.rotateCcw, + size: 16.sp, + color: clean + ? ColorConstants.acceptGreen + : ColorConstants.warning, + ), ), - SizedBox(width: 12.w), + SizedBox(width: 11.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Row( - children: [ - Expanded( - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - if (hasTime) ...[ - SizedBox(width: 8.w), - Text( - 'Skipped ${_clock(at)}', - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w700, - color: accent, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ], + Text( + 'Trip ${trip.tripNumber == 0 ? slot + 1 : trip.tripNumber}', + style: MilerType.body.copyWith( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), ), - if (address.isNotEmpty) ...[ - SizedBox(height: 3.h), - Text( - address, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w600, - height: 1.35, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), + SizedBox(height: 2.h), + Text( + _tripSummaryLine( + collected: collected, + handedOver: handedOver, + skipped: skipped, ), - ], - if (reason.isNotEmpty) ...[ - SizedBox(height: 7.h), - Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, - vertical: 4.h, - ), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular( - DesignConstants.radiusFull, - ), - ), - child: Text( - reason, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.1, - color: accent, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.micro, + ), ], ), ), - ], - ), - ), - Padding( - padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 12.h), - child: Row( - children: [ - Expanded( - child: Text( - 'Still to be collected before the end of your shift', - style: TextStyle( - fontSize: 11.sp, - height: 1.3, - fontWeight: FontWeight.w600, + if (tappable) + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: DesignConstants.motionState, + curve: Curves.easeOut, + child: Icon( + LucideIcons.chevronDown, + size: 18.sp, color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, ), ), - ), - SizedBox(width: 10.w), - MilerButton( - label: 'Resume', - icon: Icons.play_arrow_rounded, - variant: MilerButtonVariant.outlined, - color: ColorConstants.primary, - height: ButtonSizes.compact, - expand: false, - loading: busy, - onPressed: busy ? null : () => _resume(stop), - ), ], ), ), + ), + ), + ); + } + + /// The one line a collapsed trip gets. Counts, in the rider's words — never + /// a status enum, and never the internal name of the line he is on. + /// `3 collected · 3 delivered · 1 skipped`. + /// + /// Names each half only when there is one. A meal run is deliveries all the + /// way down and reads `6 delivered`; a logistics day that did both says so; + /// a run with nothing on it says nothing rather than a row of zeroes. + String _tripSummaryLine({ + required int collected, + required int handedOver, + required int skipped, + }) { + final verb = ServiceProfile.active.completionVerb.toLowerCase(); + final parts = [ + if (collected > 0) '$collected collected', + if (handedOver > 0) '$handedOver $verb', + if (skipped > 0) '$skipped skipped', + ]; + if (parts.isEmpty) return 'Nothing recorded'; + return parts.join(' · '); + } + + Widget _listView(List children) => ListView( + key: const ValueKey('activity-list'), + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB( + _gutter, + 6.h, + _gutter, + BottomPage.bottomInset(context) + 16.h, + ), + children: children, + ); + + /// Heads the one band that is not a date. + /// + // ── The date headers are gone, and with them `UNDATED` ── + // + // Activity is today's shift, so a date band above every row said the same + // word all the way down the page. The trips say something the rider does not + // already know; the date did not. + // + // The `UNDATED` header went with them, and that is the part worth recording. + // It fired for any record whose timestamp would not parse, which is a defect + // in a payload — not a fact about the rider's morning, not anything he can + // act on, and not something that belongs on a screen he reads at a gate. + // Such a record is now simply kept in today and shown like any other; see + // [ServiceDay.belongsToToday]. + + /// `a · b · c`, each piece keeping its own colour. + /// + /// A `Wrap` of separate [Text]s rather than one `RichText`: the pieces are + /// independently coloured, they have to break onto a second line at large + /// text scales rather than ellipsing away the last figure, and a real `Text` + /// per piece is what keeps them individually addressable — by a screen + /// reader, and by the tests that assert an exception is drawn in amber. + Widget _dotted(List<(String, Color)> parts, {double size = 13}) { + final style = MilerType.micro.copyWith( + fontSize: size.sp, + fontWeight: FontWeight.w600, + ); + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final (i, part) in parts.indexed) ...[ + if (i > 0) + Text( + ' · ', + style: style.copyWith(color: ColorConstants.borderStrong), + ), + Text( + part.$1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style.copyWith(color: part.$2), + ), + ], + ], + ); + } + + /// The slice is empty but the day is not — so this is about the filter, not + /// about the rider's morning, and it says so in one line with the way out + /// directly under it. + Widget _noneInFilter() { + return ListView( + key: ValueKey('activity-none-${_filter.name}'), + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB(_gutter, 60.h, _gutter, 0), + children: [ + Icon(_filter.glyph, size: 34.sp, color: ColorConstants.borderStrong), + SizedBox(height: 14.h), + Text( + _filter.emptyLine, + textAlign: TextAlign.center, + style: MilerType.body.copyWith( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + ), + ), + SizedBox(height: 12.h), + Center( + child: SizedBox( + width: 150.w, + child: MilerButton( + label: 'Show all', + variant: MilerButtonVariant.tonal, + color: ColorConstants.primary, + height: ButtonSizes.compact, + expand: false, + onPressed: () => setState(() => _filter = _ActivityFilter.all), + ), + ), + ), + ], + ); + } + + // ══════════════════════════════════════════════════════════════════════ + // STATES + // ══════════════════════════════════════════════════════════════════════ + + /// A loading state shaped like the page it is standing in for. + /// + /// The shared [SkeletonList] draws generic 40pt-icon cards, which is the + /// shape this tab stopped being: the rider watched four cards shimmer and + /// then resolve into something that was not cards, and the swap read as the + /// screen changing its mind. These bones are the strapline, the filter bar, + /// the day line and four rows — node, status, route, meta — in the places + /// they will actually land. + Widget _skeleton() { + // Aligned, because a `ListView` hands its children a *tight* cross-axis + // constraint — a bare `SkeletonBone` with a width in it comes out full + // width, which is how a skeleton stops looking like the text it stands in + // for and starts looking like a stack of grey bars. + Widget bone(double w, double h, {double radius = 5}) => Align( + alignment: AlignmentDirectional.centerStart, + child: SkeletonBone(width: w, height: h, radius: radius), + ); + + Widget row() => Padding( + padding: EdgeInsets.only(bottom: 22.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBone(width: 24.w, height: 24.w, radius: 12.w), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + bone(96.w, 13.h), + SizedBox(height: 9.h), + bone(190.w, 15.h), + SizedBox(height: 8.h), + bone(140.w, 11.h), + ], + ), + ), + SizedBox(width: 10.w), + SkeletonBone(width: 54.w, height: 12.h, radius: 5), ], ), ); + + // ── The bones are white; the shimmer is what colours them ── + // + // [SkeletonBone] paints in `pureSurface`, which is the page. It is only + // visible inside [MilerShimmer], whose `Shimmer.fromColors` recolours its + // whole subtree as the sweep passes. Without the wrapper this state renders + // as a blank screen that looks exactly like a load that has hung. + return MilerShimmer( + child: ListView( + key: const ValueKey('activity-skeleton'), + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB(_gutter, 10.h, _gutter, 0), + children: [ + bone(180.w, 13.h), + SizedBox(height: 16.h), + Row( + children: [ + SkeletonBone(width: 64.w, height: 30.h, radius: 15.h), + SizedBox(width: 8.w), + SkeletonBone(width: 74.w, height: 30.h, radius: 15.h), + SizedBox(width: 8.w), + SkeletonBone(width: 92.w, height: 30.h, radius: 15.h), + ], + ), + SizedBox(height: 20.h), + bone(210.w, 13.h), + SizedBox(height: 24.h), + bone(52.w, 11.h), + SizedBox(height: 14.h), + for (var i = 0; i < 4; i++) row(), + ], + ), + ); + } + + /// ── The empty day ── + /// + /// One glyph, one line, one way out. The illustration that used to head this + /// screen was 300pt wide on a 390pt phone — a picture of a clipboard, larger + /// than anything the page ever draws when it *does* have something to say, + /// standing in for the absence of six short rows. An empty state is a caption + /// on a screen with nothing on it; it is not a screen of its own. + Widget _empty() { + return ListView( + key: const ValueKey('activity-empty'), + // A scrollable, so the pull-to-refresh still works on an empty day. + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB(_gutter, 72.h, _gutter, 0), + children: [ + // ── The artwork says it; the words repeated it ── + // + // The glyph, the headline and the sentence under it were three + // statements of one fact — that nothing has been finished yet — on a + // screen whose emptiness already says so. The image carries it, and + // the button underneath is the only thing here the rider can act on. + // + // Sized well under the old 300pt illustration this screen once had: an + // empty state is a caption on a page with nothing on it, not a page of + // its own. + Center( + child: Image.asset( + 'assets/images/no_activity_yet.png', + // Keyed so a test can name *this* artwork rather than "an Image", + // which on a page that also draws proof photos and button glyphs + // is not a unique thing to ask for. + key: const ValueKey('activity-empty-art'), + width: 330.w, + fit: BoxFit.contain, + // A missing asset must not take the empty state down with it — + // the button below is the point of the screen. + errorBuilder: (_, _, _) => SizedBox(height: 120.h), + ), + ), + SizedBox(height: 24.h), + Center( + child: SizedBox( + width: 190.w, + child: MilerButton( + label: 'Go to ${ServiceProfile.active.workTabLabel}', + color: ColorConstants.primary, + height: ButtonSizes.compact, + expand: false, + onPressed: () => BottomPage.goToTab(1), + ), + ), + ), + ], + ); + } + + // ══════════════════════════════════════════════════════════════════════ + // ONE ACTIVITY ITEM + // ══════════════════════════════════════════════════════════════════════ + + /// ── What a rider reads in under a second ── + /// + /// ``` + /// ● Delivered 11:42 AM ⌄ + /// │ Vidhya Kitchen → Joe Mathew + /// │ 2 orders · 4.6 km · 1h 27m · Late 7m + /// │ + /// │ [the record, when it is open] + /// ``` + /// + /// **The outcome leads.** The row used to open with the customer's name, + /// which is the one fact on it a rider cannot act on and the one he is not + /// scanning for — history is opened to answer "did that go through?", and the + /// answer to that is a word. The name is still the second line, where it + /// identifies *which* stop the outcome belongs to. + /// + /// **The route is one line, not two.** `Vidhya Kitchen → Joe Mathew` says + /// where it came from and where it went in the space the address used to take + /// to say neither. The address itself is a detail for one stop and lives in + /// the record. + /// + /// **The measurements are one line of running text**, not three columns and + /// not four chips. Each figure is a `Text` of its own so an exception can be + /// amber inside a grey line without becoming a container — see [_dotted]. + /// + /// Everything else — the timings, the compliance, the parcels, the money — is + /// a *specific* question asked rarely, usually because the office is asking, + /// and it is one tap under the row. Uber, Rapido and every banking app the + /// rider uses put trip detail exactly here, for exactly this reason. + /// ── What a rider recognises, anchored ── + /// + /// ``` + /// ╭────╮ Thiru Test 2 + /// │ ✓ │ Delivered · 11:57 AM + /// ╰────╯ 4.6 km · 1h 27m › + /// ──────────────────────────────────────────────────────── + /// #DM-BK-16DA518D-6381 Details › + /// ``` + /// + /// ── Three labels came off ── + /// + /// The card carried `ORDER ID`, `CUSTOMER` and `COLLECTED AT` as eyebrows + /// over their three values: six lines to say three things, and the loudest of + /// them was `#DM-BK-16DA518D-6381` — twenty-one characters of routing + /// reference set as the headline of a card a rider scans by *name*. He does + /// not know a stop by its booking id; he knows it by who was at the door. + /// + /// So the name leads, the outcome and the clock share the line under it, and + /// the reference drops to a footer in the size a lookup key deserves. It is + /// still on the card — somebody in the office reads it down a phone at him — + /// it just is not pretending to be the subject. + /// + /// ── The tile is the anchor ── + /// + /// A 44pt status-tinted square with the state's glyph in it. Every history + /// list the rider already uses has one, and it is doing real work here: it + /// gives the eye a fixed left edge to run down at speed, and it carries the + /// outcome in shape and colour before a single word is read. The status is + /// then a *word* beside the clock rather than a second badge in the corner — + /// a tile and a pill saying the same thing is one of them too many. + /// ── One line of a shift log, not a card ── + /// + /// Every finished stop wore a `GlassCard`: a surface, a border, a shadow and + /// a 44dp tinted tile with a glyph in it, repeated down the page. Twelve + /// stops was twelve boxes, and the effect of giving every completed delivery + /// its own container is that none of them recedes — a settled stop, which is + /// the overwhelming majority of this screen, should be the quietest thing on + /// it. + /// + /// So the container is gone and the rail replaces it. A small marker on a + /// hairline connects the stops of a run into one visible sequence, which is + /// also the shape the record page uses for its journey: the rider sees the + /// same picture of his run in both places. + /// + /// Ranked: the customer is the strongest text (it is how he knows the stop), + /// the outcome is the second, the clock is right-aligned where the eye can + /// run down it, and the route and figures are the quiet lines beneath. The + /// booking reference is **not** here — it is a lookup key for an office, it + /// lives on the record page, and a rider should not be scanning past + /// database identifiers to find a name. + Widget _row( + Map stop, { + bool first = false, + bool last = false, + }) { + final id = (stop['orderid'] ?? '').toString(); + final status = stopStatusOf(stop); + final at = _happenedAt(stop); + final hasTime = at.millisecondsSinceEpoch > 0; + final c = StopCompliance.of(stop); + + final bool parked = status.isSkipped && stop['canresume'] == true; + // A door-step skip: shown like a parked stop, minus the control. See the + // merge in `_load`. + final bool attempted = status.isSkipped && !parked; + final bool closed = status.isCancelled || status.isRejected; + // A skip of either kind is not a late delivery — there was no delivery. + final bool late = !closed && !status.isSkipped && c.onTime == false; + + // The word, the colour and the glyph come from the canonical list — + // [StopStatusX], next to the parser that produces the states — never a + // switch written here. + final Color mark = status.color; + final ends = routeEndsOf(stop); + final reason = (parked || attempted) + ? (stop['skipreason'] ?? stop['notes'] ?? '').toString().trim() + : ''; + final meta = _meta(stop, late: late, c: c); + + final proof = stop['proof']; + final double rowCash = (proof is Map && proof['amountcollected'] is num) + ? (proof['amountcollected'] as num).toDouble() + : stopCollectionAmount(stop); + + final who = (ends.to ?? ends.from).trim(); + + return Semantics( + button: id.isNotEmpty, + label: '${status.label}, ${routeLineOf(stop)}', + hint: id.isEmpty ? null : 'Opens the delivery record', + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: id.isEmpty ? null : () => _openDetails(context, stop), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _railColumn( + mark: mark, + icon: status.icon, + first: first, + last: last, + ), + SizedBox(width: 12.w), + Expanded( + child: Padding( + // ── The gap answers to what is actually in the row ── + // + // Seen on a device: a logistics record carries no + // completion clock, no route line and no measurements — + // `/miler/bookings` returns none of them — so most rows + // are a name and a word. At a fixed 18 of separation that + // is a row which is mostly padding, and seven of them fill + // a phone that should hold twelve. + // + // The wide gap is there to separate *two-and-three-line* + // rows on a meal run. A short row does not need it. + padding: EdgeInsets.only( + top: 8.h, + bottom: last + ? 8.h + : (meta.isEmpty && ends.to == null ? 10.h : 18.h), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + who.isEmpty ? 'Customer' : who, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + ), + // ── The figures give ground, and stop growing ── + // + // A fixed money column plus a fixed clock beside a + // flexible name overflows the row from about 1.6x + // system text — 122pt off a 320pt phone at 2.0x, + // which is a black-and-yellow band across the one + // line that says who the stop was. + // + // Clamped rather than shrunk: `₹2,450` and `5:08 + // PM` are read at a glance, not studied, so they + // stop scaling at 1.3 while the name — the thing + // the rider is actually looking for — keeps + // scaling all the way up. + if (rowCash > 0 || hasTime) + MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (rowCash > 0) ...[ + SizedBox(width: 8.w), + Text( + rupees(rowCash), + maxLines: 1, + style: MilerType.figure( + 13.5, + color: ColorConstants.moneyGreen, + ).copyWith(fontWeight: FontWeight.w700), + ), + ], + if (hasTime) ...[ + SizedBox(width: 10.w), + Text( + RouteMetricsHelper.formatClock(at), + maxLines: 1, + style: MilerType.figure( + 13, + color: ColorConstants.secondaryText, + ).copyWith(fontWeight: FontWeight.w500), + ), + ], + ], + ), + ), + ], + ), + SizedBox(height: 2.h), + // ── The word does not repeat the rail ── + // + // The rail disc beside this row already carries the + // outcome in shape and colour, and the card's own rule + // is that a tile and a pill saying the same thing is + // one too many. Painted green, a day of 25 delivered + // stops was a column of 25 green words — and a page + // where everything is coloured has no colour left for + // the row that went wrong. + // + // So the norm is quiet ink and the exception keeps the + // status colour. And on a logistics row — which the + // payload leaves as a bare name and a word — the line + // earns its keep by saying *where*: `Delivered · + // Peelamedu` when there is no route line to say it. + Text( + [ + status.label, + if (ends.to == null) + if (areaOf(stop) case final a when a.isNotEmpty) + a, + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, + color: + (status.isSkipped || + status.isCancelled || + status.isRejected) + ? mark + : ColorConstants.secondaryText, + ), + ), + // The route, and only when there are two ends to it — + // a collection has one place and printing it twice + // reads as a journey that went nowhere. + if (ends.to != null && ends.from.trim().isNotEmpty) ...[ + SizedBox(height: 3.h), + Text( + routeLineOf(stop), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith(fontSize: 12.5.sp), + ), + ], + if (meta.isNotEmpty) ...[ + SizedBox(height: 4.h), + Row( + children: [ + Flexible(child: _dotted(meta, size: 12.5)), + if (id.isNotEmpty) ...[ + SizedBox(width: 8.w), + Icon( + LucideIcons.chevronRight, + size: 14.sp, + color: ColorConstants.borderStrong, + ), + ], + ], + ), + ], + // ── The one row that still owes something ── + // + // A finished stop has nothing left to do and stays + // quiet. A parked one has exactly one thing, and it + // gets a compact control rather than a full-width + // button: a page of history is not a page of actions, + // and a big button on every row would make the + // exception look like the rule. + if (reason.isNotEmpty) ...[ + SizedBox(height: 5.h), + Text( + reason, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.warning, + ), + ), + ], + if (parked) ...[ + SizedBox(height: 4.h), + _resumeAction(stop, id), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// The rail and this stop's marker. + /// + /// 22dp, tinted, with the status glyph — a quarter of the area the old tile + /// took. It is an anchor for the eye and a colour cue, not a badge. + Widget _railColumn({ + required Color mark, + required IconData icon, + required bool first, + required bool last, + }) { + return SizedBox( + width: 24.w, + child: Column( + children: [ + SizedBox( + height: 8.h, + child: first + ? null + : Center( + child: Container( + width: 1.5, + color: ColorConstants.borderSubtle, + ), + ), + ), + Container( + width: 22.w, + height: 22.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: mark.withValues(alpha: 0.13), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 12.sp, color: mark), + ), + if (!last) + Expanded( + child: Center( + child: Container( + width: 1.5, + color: ColorConstants.borderSubtle, + ), + ), + ), + ], + ), + ); + } + + /// `Resume stop →`, at a real tap target and with the guards intact. + /// + /// Compact, but the row it sits in is 48dp tall including its padding, and + /// the loading/disabled protection is the same the full-width button had: + /// an id already in flight cannot be pressed twice. The resume rules + /// themselves are untouched — only a stop the data layer marks resumable + /// reaches this at all. See `_load`'s merge and `MyPickups.resumeSkippedStop`. + Widget _resumeAction(Map stop, String id) { + final busy = _busyIds.contains(id); + return Align( + alignment: Alignment.centerLeft, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: busy ? null : () => _resume(stop), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 9.h), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (busy) + SizedBox( + width: 12.w, + height: 12.w, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + ColorConstants.primary, + ), + ), + ) + else + Icon( + LucideIcons.rotateCcw, + size: 13.sp, + color: ColorConstants.primary, + ), + SizedBox(width: 6.w), + Text( + 'Resume stop', + style: MilerType.body.copyWith( + fontSize: 13.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.primary, + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// Opens one stop's record on its own page. + /// + /// A real route push, not a sheet: it has its own back button, its own scroll + /// position and its own place in the history stack, which is what lets a + /// rider read a long record without losing where he was in the day. + Future _openDetails(BuildContext context, Map stop) => + openScreen(context, DeliveryDetailsPage(stop: stop)); + + /// The measurements line: what was carried, how far, how long, what was + /// taken, and the one exception that gets a word. + /// + /// Every piece is dropped when the app did not measure it. A stop closed in + /// bulk from Home has no departure time and therefore no duration, and + /// printing `0m` for it would be an invention that cannot later be caught — + /// same rule [StopCompliance] follows. + List<(String, Color)> _meta( + Map stop, { + required bool late, + required StopCompliance c, + }) { + final grey = ColorConstants.secondaryText; + final started = stampOf(stop, const ['startedat']); + final done = stampOf(stop, const [ + 'completedat', + 'pickedtime', + 'picked_time', + ]); + final took = (started != null && done != null) + ? done.difference(started) + : null; + + final kind = MilkRun.workingKind(stop); + final int parcels = kind.isDelivery + ? deliveryParcelCount(stop) + : pickupParcelCount(stop); + final cash = stopCollectionAmount(stop); + + // ── What a collection produced ── + // + // On logistics the rider's day is *both* legs — he collects shipments and + // he delivers them — and Activity lists both, because + // `StopStatusX.isWorkComplete` treats `picked` as finished on a line that + // ends at a hub. What the record did not say is what a collection actually + // *created*: the rider raised a shipment at that door, weighed it and + // priced it, and the row reported only that he had been there. + // + // The weight is the figure that makes it a shipment rather than a visit — + // it is what the consignment is billed on and the one number an office + // asks him about later. Read from the proof the verify screen wrote, so a + // stop that was never weighed shows nothing rather than a zero. + final proof = stop['proof'] is Map + ? Map.from(stop['proof'] as Map) + : const {}; + final weight = (proof['weight'] ?? stop['weight'] ?? '').toString().trim(); + final showWeight = + !kind.isDelivery && + ServiceProfile.active.pricesShipment && + weight.isNotEmpty && + weight != '0'; + + return <(String, Color)>[ + if (parcels > 0) + ( + // A collection *raised* the orders; a delivery *handed them over*. + // The same figure means two different things and the line says + // which, because on this line both are in the same list. + kind.isDelivery + ? '$parcels ${parcels == 1 ? 'order' : 'orders'}' + : '$parcels ${parcels == 1 ? 'order' : 'orders'} raised', + grey, + ), + if (showWeight) ('$weight kg', grey), + if (c.actualKm != null && c.actualKm! > 0) + ('${kmText(c.actualKm!)} km', grey), + if (took != null && took.inSeconds > 0) (shortDuration(took), grey), + if (ServiceProfile.active.collectsCash && cash > 0) + (rupees(cash), ColorConstants.moneyGreen), + // The norm says nothing; the exception is the only thing that gets a + // word, in the warning colour, on the row it happened to. + if (late) + ( + 'Late ${shortDuration(c.lateBy ?? Duration.zero)}', + ColorConstants.warning, + ), + ]; } /// Puts a parked stop back into the working queue and takes the rider there. /// /// The wire call, the two stores and the poller's skip cache are all handled /// by [MyPickups.resumeSkippedStop] — this is only the screen's half: keep the - /// button from firing twice, drop the row, and follow the stop to Bookings, - /// because a rider who taps Resume is going there next either way. + /// button from firing twice, drop the row, and follow the stop to the work + /// tab, because a rider who taps Resume is going there next either way. Future _resume(Map stop) async { final id = (stop['orderid'] ?? '').toString(); if (id.isEmpty || _busyIds.contains(id)) return; @@ -687,853 +1797,16 @@ class _ActivityPageState extends State { .where((s) => (s['orderid'] ?? '').toString() != id) .toList(); }); - AppFeedback.success(context, 'Back on your Bookings tab'); + // The tab's own name, not a literal. It reads "Deliveries" on a meal + // run, and a message naming a tab the rider cannot see is the same + // mistake the page title carried until it started reading the profile. + AppFeedback.success( + context, + 'Back on your ${ServiceProfile.active.workTabLabel} tab', + ); BottomPage.goToTab(1); } finally { if (mounted) setState(() => _busyIds.remove(id)); } } - - /// The trip in two lines: what he finished and what he is carrying, then how - /// the run scored. - /// - /// Cash is the figure with consequences — Miler is the carrier, so that money - /// is his personal liability until he reconciles it at the hub. The - /// compliance line under it is the same pair the reward is paid on, totalled, - /// so he does not have to add up the chips himself. - Widget _daySummary(double cash, int count, ComplianceSummary c) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 8.h), - Row( - children: [ - Expanded( - child: Text( - // "…completed on this trip" ellipsised to "…on this t…" the - // moment there was cash beside it. The trip tab directly above - // already says which run this is, so the words were paying for - // width to repeat it. - '$count ${count == 1 ? 'stop' : 'stops'} completed', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - if (cash > 0) ...[ - SizedBox(width: 10.w), - Text( - '₹${cash % 1 == 0 ? cash.toStringAsFixed(0) : cash.toStringAsFixed(1)} collected', - style: TextStyle( - fontSize: 13.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - color: ColorConstants.moneyGreen, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ], - ), - if (c.measured > 0) ...[ - SizedBox(height: 7.h), - Row( - children: [ - Expanded( - child: _tally( - icon: Icons.schedule_rounded, - label: 'on time', - hit: c.onTime, - total: c.measured, - ), - ), - Expanded( - child: _tally( - icon: Icons.alt_route_rounded, - label: 'on route', - hit: c.onRoute, - total: c.measured, - ), - ), - Expanded( - child: _tally( - icon: Icons.workspace_premium_rounded, - label: c.points == 1 ? 'point' : 'points', - hit: c.points, - total: null, - ), - ), - ], - ), - ], - SizedBox(height: 18.h), - ], - ); - } - - /// `4/5 on time`. [total] null prints the figure alone — points are a count, - /// not a fraction of anything. - Widget _tally({ - required IconData icon, - required String label, - required int hit, - required int? total, - }) { - final bool full = total == null ? hit > 0 : hit == total; - final Color accent = full - ? ColorConstants.acceptGreen - : ColorConstants.secondaryText; - - return Row( - children: [ - Icon(icon, size: 13.sp, color: accent), - SizedBox(width: 5.w), - Flexible( - child: Text( - total == null ? '$hit $label' : '$hit/$total $label', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.1, - color: accent, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], - ); - } - - /// One finished stop: a summary line that opens into the whole record. - /// - /// ── Why the detail is behind a tap ── - /// - /// Everything a rider asks of a finished stop is a *specific* question asked - /// rarely — "what time did I get there?", "how far was that one?", "did I - /// take the cash?" — usually because someone in the office is asking him. The - /// answers were being measured and thrown away, or buried in a payload nobody - /// rendered. But printing all of them on every row would turn a day's work - /// into a wall of figures and make the list useless for the thing it is for, - /// which is scanning what happened. - /// - /// So the row stays a row, and the record is one tap under it. Uber, Rapido - /// and every banking app the rider uses put trip detail exactly here, for - /// exactly this reason. - Widget _row(Map stop) { - final id = (stop['orderid'] ?? '').toString(); - final open = _openIds.contains(id); - final kind = stopKindOf(stop); - final status = stopStatusOf(stop); - final cancelled = status.isCancelled; - final name = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop') - .toString(); - final address = (stop['pickupaddress'] ?? stop['PickupAddress'] ?? '') - .toString() - .trim(); - final at = _happenedAt(stop); - final hasTime = at.millisecondsSinceEpoch > 0; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: id.isEmpty - ? null - : () => setState(() { - open ? _openIds.remove(id) : _openIds.add(id); - }), - child: Padding( - // ── No avatar, no card, no inset ── - // - // The row opened with a 40pt tinted disc holding a tick, on every - // row, on a list where every row is a finished stop. A mark that is - // identical on all of them separates nothing — it just moved the - // name 53pt right and stole the width the address needed. What the - // disc was actually encoding, stop type, is a word now. - padding: EdgeInsets.symmetric(vertical: 15.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - if (hasTime) ...[ - SizedBox(width: 8.w), - Text( - _clock(at), - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ], - ), - if (address.isNotEmpty) ...[ - SizedBox(height: 4.h), - Text( - address, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w500, - height: 1.35, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - SizedBox(height: 8.h), - // ── Two filled chips became one dotted word ── - // - // The type was a tinted tag and the order id another one - // beside it, so every row carried two small coloured - // rectangles under the address — the loudest things on a - // card whose subject is the figures below them. Same - // information, ranked: a 6pt dot for the type, the - // reference in the grey it deserves, still one tap to - // copy. - Row( - children: [ - Container( - width: 6.w, - height: 6.w, - margin: EdgeInsets.only(right: 7.w), - decoration: BoxDecoration( - color: cancelled - ? ColorConstants.secondaryText - : kind.accent, - shape: BoxShape.circle, - ), - ), - Flexible( - child: Text( - cancelled ? 'Cancelled' : kind.titleLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w700, - color: cancelled - ? ColorConstants.secondaryText - : kind.accent, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - if (id.isNotEmpty) ...[ - SizedBox(width: 10.w), - _orderIdChip(id), - ], - ], - ), - - // ── Figures you can hold two stops up against ── - // - // Three cells, always in the same order, always in the - // same place, so the eye can run straight down a list and - // compare like with like. Prose does not line up; columns - // do. The verdicts colour the figures they belong to - // rather than sitting apart from them — being late is a - // fact about the time, not a separate badge. - // - // They used to line up only *within* a card, because each - // card was inset by its own padding on a page of cards. - // Now that the rows share the page's margin, column two of - // one stop sits directly above column two of the next. - if (!cancelled) ...[ - SizedBox(height: 13.h), - _statStrip(stop), - ], - ], - ), - ), - SizedBox(width: 10.w), - // The one affordance that says the row does something. It - // turns rather than swaps, so the row reads as opening rather - // than as having been replaced. - AnimatedRotation( - turns: open ? 0.5 : 0, - duration: const Duration(milliseconds: 200), - child: Icon( - Icons.keyboard_arrow_down_rounded, - size: 20.sp, - color: ColorConstants.secondaryText, - ), - ), - ], - ), - ), - ), - AnimatedCrossFade( - firstChild: const SizedBox(width: double.infinity), - secondChild: _stopDetail(stop), - crossFadeState: open - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 220), - sizeCurve: Curves.easeOutCubic, - ), - ], - ); - } - - // ── The record behind a finished stop ──────────────────────────────────── - // - // Four questions, in the order they get asked of a rider: when did this - // happen, how far did I ride, what was it, and did money change hands. - // - // ── "Not recorded" is a real answer here ── - // - // Every figure below comes from somewhere that can legitimately be empty — a - // stop closed in bulk from Home never opened the map, so it has no departure - // time; a hub that assigns no ETA leaves nothing to judge. Saying so is the - // whole point: a record that invents a plausible number is worse than one - // with a gap in it, because the gap can be investigated and the invention - // cannot be caught. Same rule the compliance chips already follow. - Widget _stopDetail(Map stop) { - final kind = stopKindOf(stop); - final proof = stop['proof'] is Map - ? Map.from(stop['proof'] as Map) - : const {}; - final c = StopCompliance.of(stop); - - final started = _stamp(stop, const ['startedat']); - final arrived = _stamp(stop, const ['arrivedat', 'arrivedtime']); - final done = _stamp(stop, const [ - 'completedat', - 'pickedtime', - 'picked_time', - ]); - - final rode = (started != null && arrived != null) - ? arrived.difference(started) - : null; - final atDoor = (arrived != null && done != null) - ? done.difference(arrived) - : null; - - final cash = stopCollectionAmount(stop); - final collected = proof['amountcollected'] is num - ? (proof['amountcollected'] as num).toDouble() - : null; - final method = (proof['paymentmethod'] ?? '').toString().trim(); - final weight = (proof['weight'] ?? stop['weight'] ?? '').toString().trim(); - final parcels = proof['collected'] ?? proof['handedover']; - final address = (stop['pickupaddress'] ?? stop['PickupAddress'] ?? '') - .toString() - .trim(); - - return Padding( - padding: EdgeInsets.only(bottom: 16.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _detailGroup('Timing', [ - _detailRow('Set off', started == null ? null : _clock(started)), - _detailRow( - 'Arrived', - arrived == null ? null : _clock(arrived), - note: rode == null ? null : '${_short(rode)} on the road', - ), - _detailRow( - kind.isDelivery ? 'Delivered' : 'Collected', - done == null ? null : _clock(done), - note: atDoor == null ? null : '${_short(atDoor)} at the stop', - ), - _detailRow('ETA given', _plannedEta(stop)), - if (c.onTime != null) - _detailRow( - 'Against the ETA', - c.onTime! - ? 'On time' - : 'Late by ${_short(c.lateBy ?? Duration.zero)}', - good: c.onTime, - ), - ]), - _detailGroup('Distance', [ - _detailRow( - 'Ridden to this stop', - c.actualKm == null ? null : '${_km(c.actualKm!)} km', - ), - _detailRow( - 'Route allowed for', - c.plannedKm == null ? null : '${_km(c.plannedKm!)} km', - ), - if (c.extraKm != null) - _detailRow('Extra ridden', '+${_km(c.extraKm!)} km', good: false), - ]), - _detailGroup('Order', [ - _detailRow('Order ID', (stop['orderid'] ?? '').toString()), - _detailRow('Type', kind.badgeLabel), - _detailRow( - kind.isDelivery ? 'Parcels handed over' : 'Parcels collected', - parcels == null ? null : '$parcels', - ), - _detailRow('Weight', weight.isEmpty ? null : '$weight kg'), - if ((stop['tenantname'] ?? '').toString().trim().isNotEmpty) - _detailRow('Shipper', stop['tenantname'].toString()), - _detailRow('Address', address.isEmpty ? null : address, wrap: true), - ]), - _detailGroup('Payment', [ - if (cash <= 0 && collected == null) - _detailRow('Collected', 'Nothing to collect — prepaid') - else ...[ - _detailRow( - 'Due at this stop', - cash > 0 ? '₹${_money(cash)}' : null, - ), - _detailRow( - 'Collected', - collected == null ? null : '₹${_money(collected)}', - good: collected == null ? null : collected >= cash, - ), - _detailRow( - 'Method', - method.isEmpty - ? null - : (method.toLowerCase().contains('upi') - ? 'UPI / QR' - : 'Cash'), - ), - ], - ]), - ], - ), - ); - } - - /// A titled block of rows. Drops itself when every row is unknown — a section - /// made entirely of "Not recorded" is noise. - Widget _detailGroup(String title, List rows) { - final live = rows.whereType().toList(); - if (live.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 12.h), - Text( - title.toUpperCase(), - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.8, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 6.h), - ...live, - ], - ); - } - - /// `Arrived 09:42`, with an optional grey note under the value and - /// an optional green/amber tint when the figure is a verdict. - /// - /// Returns null for an unknown value so [_detailGroup] can drop a whole - /// section that turned out to have nothing in it. - Widget? _detailRow( - String label, - String? value, { - String? note, - bool? good, - bool wrap = false, - }) { - if (value == null || value.trim().isEmpty) return null; - - final Color valueColor = good == null - ? ColorConstants.slateText - : good - ? ColorConstants.acceptGreen - : ColorConstants.warning; - - return Padding( - padding: EdgeInsets.symmetric(vertical: 5.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 116.w, - child: Text( - label, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - SizedBox(width: 8.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - value, - textAlign: TextAlign.right, - maxLines: wrap ? 3 : 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.5.sp, - height: 1.35, - fontWeight: FontWeight.w800, - letterSpacing: -0.1, - color: valueColor, - fontFamily: FontConstants.fontFamily, - ), - ), - if (note != null) - Text( - note, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ), - ], - ), - ); - } - - /// The ETA the hub gave for this stop — minutes allowed, or the clock time it - /// was due by, whichever the booking carries. - String? _plannedEta(Map stop) { - final minutes = int.tryParse((stop['eta'] ?? '').toString().trim()); - if (minutes != null && minutes > 0) return '$minutes min allowed'; - - final due = _stamp(stop, const [ - 'expected_pickup_time', - 'expectedpickuptime', - 'slotendtime', - ]); - return due == null ? null : 'By ${_clock(due)}'; - } - - /// First parseable timestamp among [keys]. - DateTime? _stamp(Map stop, List keys) { - for (final k in keys) { - final raw = (stop[k] ?? '').toString().trim(); - if (raw.isEmpty) continue; - final parsed = DateTime.tryParse(raw); - if (parsed != null) return parsed; - } - return null; - } - - String _km(double v) => v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1); - - String _money(double v) => - v % 1 == 0 ? v.toStringAsFixed(0) : v.toStringAsFixed(1); - - /// `14:05` → `2:05 PM`. The same shape as the clock the map sheet's ETA - /// prints, so a time read on the way to a stop and the time recorded against - /// it afterwards are written the same way. - String _clock(DateTime t) { - final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12; - final minute = t.minute.toString().padLeft(2, '0'); - return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}'; - } - - /// A duration at a glance: `6m`, `1h 12m`. Seconds are never shown — nothing - /// on this screen is decided to the second, and they only make the figure - /// harder to compare against the one on the card below it. - String _short(Duration d) { - final total = d.inMinutes; - if (total < 1) return '<1m'; - final hours = total ~/ 60; - final minutes = total % 60; - if (hours == 0) return '${minutes}m'; - return minutes == 0 ? '${hours}h' : '${hours}h ${minutes}m'; - } - - /// The booking reference, and one tap to put it on the clipboard. - /// - /// ── Why this needed to be a control at all ── - /// - /// The order ID was a line of grey text inside the expanded detail. It is the - /// one field on this screen a rider ever has to reproduce somewhere else — in - /// a WhatsApp message to the hub, on a support call, in a complaint about a - /// stop that was marked wrong — and reproducing a twelve-character reference - /// by reading it off a phone and typing it into another app is exactly the - /// task people get wrong. Now it is on the card, in a monospaced-feeling - /// tracked-out face so the characters are individually legible, and one tap - /// copies it. - Widget _orderIdChip(String id) { - return Flexible( - child: Semantics( - button: true, - label: 'Copy order ID $id', - excludeSemantics: true, - child: InkWell( - onTap: () => _copyOrderId(id), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - // Unfilled. A tinted rectangle made the reference compete with the - // stop type beside it and with the figures below, and it is neither - // a state nor a category — it is a string the rider occasionally has - // to read out. Grey text and a copy glyph say "reproducible" without - // claiming any of the row's rank; the tap target is unchanged. - child: Padding( - padding: EdgeInsets.symmetric(vertical: 2.h), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - '#$id', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - SizedBox(width: 4.w), - Icon( - Icons.copy_rounded, - size: 11.5.sp, - color: ColorConstants.secondaryText, - ), - ], - ), - ), - ), - ), - ); - } - - Future _copyOrderId(String id) async { - await Clipboard.setData(ClipboardData(text: id)); - if (!mounted) return; - HapticFeedback.selectionClick(); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - backgroundColor: ColorConstants.slateText, - duration: const Duration(seconds: 2), - // Clear of the floating nav bar, which is Positioned outside the - // Scaffold body and would otherwise sit on top of this. - margin: EdgeInsets.fromLTRB( - 16.w, - 0, - 16.w, - BottomPage.bottomInset(context) + 8.h, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - content: Row( - children: [ - Icon( - Icons.check_circle_rounded, - size: 16.sp, - color: Colors.white, - ), - SizedBox(width: 8.w), - Expanded( - child: Text( - 'Order $id copied', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w700, - color: Colors.white, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], - ), - ), - ); - } - - /// ── Three figures, in three fixed columns ── - /// - /// This replaces a `_complianceRow` of loose chips. The chips said "On time" - /// and "Took the route" without the numbers behind them, so a rider could see - /// that a stop had gone wrong but not how badly, and nothing lined up between - /// one card and the next. - /// - /// Each cell is a caption, a figure, and a note — and the figure carries the - /// verdict as its colour, because being late is a fact about the time rather - /// than a separate badge sitting near it. An unmeasured figure says so; see - /// the note on [_stopDetail] for why inventing one would be worse. - Widget _statStrip(Map stop) { - final c = StopCompliance.of(stop); - - final started = _stamp(stop, const ['startedat']); - final done = _stamp(stop, const [ - 'completedat', - 'pickedtime', - 'picked_time', - ]); - final took = (started != null && done != null) - ? done.difference(started) - : null; - - final cash = stopCollectionAmount(stop); - final proof = stop['proof'] is Map - ? Map.from(stop['proof'] as Map) - : const {}; - final collected = proof['amountcollected'] is num - ? (proof['amountcollected'] as num).toDouble() - : null; - - // ── No panel, no rules ── - // - // This was a white block with two vertical dividers, sitting inside the - // grey card that held the row: the card-inside-card the whole page was made - // of. Both were doing the job that alignment already does — three `Expanded` - // cells land in the same three places on every row whether or not anything - // is drawn around them, and on a ruled list they now line up with the row - // above as well as within themselves. - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _statCell( - 'TIME', - took == null ? '—' : _short(took), - note: c.onTime == null - ? null - : c.onTime! - ? 'On time' - : 'Late ${_short(c.lateBy ?? Duration.zero)}', - good: c.onTime, - ), - _statCell( - 'RIDDEN', - c.actualKm == null ? '—' : '${_km(c.actualKm!)} km', - note: c.plannedKm == null - ? null - : c.extraKm == null - ? 'On route' - : '+${_km(c.extraKm!)} over', - good: c.onRoute, - ), - _statCell( - 'COLLECTED', - cash <= 0 && collected == null - ? 'Prepaid' - : '₹${_money(collected ?? cash)}', - note: collected == null || cash <= 0 - ? null - : collected >= cash - ? 'Settled' - : 'Short ₹${_money(cash - collected)}', - good: collected == null || cash <= 0 ? null : collected >= cash, - ), - ], - ), - ); - } - - Widget _statCell(String caption, String value, {String? note, bool? good}) { - final Color valueColor = good == null - ? ColorConstants.slateText - : good - ? ColorConstants.acceptGreen - : ColorConstants.warning; - - return Expanded( - child: Padding( - // Only trailing: column one starts flush with the name and the address - // above it, so the row has a single left edge rather than a card's - // worth of inset inside it. - padding: EdgeInsets.only(right: 10.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - caption, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 9.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.7, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 4.h), - Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 17.sp, - height: 1.1, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: valueColor, - fontFamily: FontConstants.fontFamily, - ), - ), - if (note != null) ...[ - SizedBox(height: 3.h), - Text( - note, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.5.sp, - fontWeight: FontWeight.w700, - color: good == null - ? ColorConstants.secondaryText - : valueColor.withValues(alpha: 0.85), - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ], - ), - ), - ); - } } diff --git a/lib/views/Dashboard/activity/delivery_details_page.dart b/lib/views/Dashboard/activity/delivery_details_page.dart new file mode 100644 index 0000000..106932e --- /dev/null +++ b/lib/views/Dashboard/activity/delivery_details_page.dart @@ -0,0 +1,1504 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/stop_area.dart'; +import 'package:miler/data/order_events.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/stop_compliance.dart'; +import 'package:miler/views/Dashboard/activity/activity_format.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// DELIVERY DETAILS — one stop, the whole story +/// +/// ── Why this is a page and not an expanding row ── +/// +/// The record used to open *inside* the Activity list. Two things were wrong +/// with that, and the second is the reason it moved. +/// +/// The list stopped being scannable the moment it was used. An open record is +/// several screens tall, so the row under it left the viewport — and left what +/// a lazy `ListView` had built — and the rider scrolled through one stop's +/// measurements to reach the next stop's name. A history page is *scanned*, and +/// anything that makes scanning harder in proportion to use is the wrong model. +/// +/// And the two jobs want opposite things. The list answers "did that go +/// through?" in one word and must stay one word per row. This answers "what +/// happened on that one?", which is a timeline, a route, four measurements and +/// a reference — the whole of which is worth a screen and none of which is +/// worth a row. Every consumer delivery app the rider already uses splits them +/// exactly here. +/// +/// ── What it does not do ── +/// +/// It takes the record it is given and renders it. No fetch, no store write, no +/// status write. `Activity` owns the data; this owns the reading of it. A +/// screen that re-fetched could disagree with the row that opened it, which is +/// the one thing history must never do. +/// ───────────────────────────────────────────────────────────────────────── +class DeliveryDetailsPage extends StatelessWidget { + /// The finished stop, exactly as the Activity list holds it. + final Map stop; + + const DeliveryDetailsPage({super.key, required this.stop}); + + @override + Widget build(BuildContext context) { + final status = stopStatusOf(stop); + final ends = routeEndsOf(stop); + final at = happenedAt(stop); + final c = StopCompliance.of(stop); + + final bool closed = status.isCancelled || status.isRejected; + final bool late = !closed && !status.isSkipped && c.onTime == false; + + return Scaffold( + // ── A rider's receipt, not an operations record ── + // + // This was four grouped panels on a soft ground — verdict, journey, + // proof, ledger — which is the grammar the Deliveries *queue* uses, + // where each card is a different stop and the surface change is what + // separates them. Here every panel is the same stop. The panels were + // dividing one subject into four, and a page of boxes reads as a form to + // work through rather than an answer to a question. + // + // One sheet now, and the question it answers in the first second is + // **what happened, to whom, when** — the hero. Everything after it is + // the story (the journey), the evidence (the photograph), and then three + // folded rows for the facts somebody in an office might ask about weeks + // later. Those facts have not been removed; they have been ranked. + backgroundColor: ColorConstants.daylightSurface, + appBar: const MilerAppBar(title: 'Delivery details', showBack: true), + body: ListView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(0, 0, 0, 40.h), + children: [ + Container( + padding: EdgeInsets.fromLTRB(20.w, 26.h, 20.w, 8.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(DesignConstants.radiusXl), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _hero(status, ends, at, late: late, c: c), + _metricLine(c), + _journey(status, ends), + _proofPanel(), + _folded(c), + ], + ), + ), + ], + ), + ); + } + + /// ── Who, what and when, in one second ── + /// + /// The verdict used to be a 24sp status beside a disc, with the route as an + /// 18sp heading under it and the customer's name buried inside that heading. + /// Two headline-weight lines competing, and the one fact the rider is + /// actually looking for — *which stop is this* — was a fragment of the + /// smaller one. + /// + /// Ranked properly: the mark and the outcome, the person, the clock. The + /// route drops to a quiet line underneath, because by the time he needs to + /// know where it came from he has already recognised the stop. + Widget _hero( + StopStatus status, + ({String from, String? to}) ends, + DateTime at, { + required bool late, + required StopCompliance c, + }) { + final mark = status.color; + final who = (ends.to ?? ends.from).trim(); + final note = _outcomeNote(status); + final hasTime = at.millisecondsSinceEpoch > 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: mark.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: SizedBox( + width: 52.w, + height: 52.w, + child: Icon(status.icon, size: 24.sp, color: mark), + ), + ), + SizedBox(height: 12.h), + Text( + status.label, + textAlign: TextAlign.center, + style: MilerType.body.copyWith( + fontSize: 23.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + color: mark, + ), + ), + SizedBox(height: 3.h), + Text( + who.isEmpty ? 'Customer' : who, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + if (hasTime) ...[ + SizedBox(height: 2.h), + Text( + RouteMetricsHelper.formatClock(at), + style: MilerType.caption.copyWith(fontSize: 14.sp), + ), + ], + if (ends.to != null && ends.from.trim().isNotEmpty) ...[ + SizedBox(height: 10.h), + // ── The arrow is a glyph, not a character ── + // + // `→` (U+2192) is not in Poppins. Text set in the app's own family + // therefore rendered it as the fallback's hollow box — a tofu square + // in the middle of the one line that says where this parcel came + // from and where it went. Drawn as an icon it cannot go missing, and + // it takes the same grey as the names either side of it. + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + ends.from, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: MilerType.micro.copyWith(fontSize: 13.sp), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 7.w), + child: Icon( + LucideIcons.arrowRight, + size: 13.sp, + color: ColorConstants.secondaryText, + ), + ), + Flexible( + child: Text( + ends.to!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith(fontSize: 13.sp), + ), + ), + ], + ), + ], + if (note != null || late) ...[ + SizedBox(height: 10.h), + Text( + note ?? 'Arrived later than planned', + textAlign: TextAlign.center, + style: MilerType.micro.copyWith( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: late + ? ColorConstants.warning + : ColorConstants.secondaryText, + ), + ), + ], + ], + ); + } + + /// The one line the outcome needs and the status word does not carry. + /// + /// Who took it, or why it did not happen. Null on the ordinary case — a + /// delivery that went through has nothing to add, and a caption saying so + /// would be furniture on every record. + String? _outcomeNote(StopStatus status) { + if (status.isSkipped) { + final why = (stop['skipreason'] ?? stop['notes'] ?? '').toString().trim(); + return why.isEmpty ? 'Return visit owed' : why; + } + if (status.isCancelled || status.isRejected) { + final why = (stop['notes'] ?? '').toString().trim(); + return why.isEmpty ? null : why; + } + final to = (stop['deliveredtoname'] ?? stop['receivedby'] ?? '') + .toString() + .trim(); + return to.isEmpty ? null : 'Handed to $to'; + } + + /// `4.6 km · 27 min · 1 order` — the whole stats strip, as one line. + /// + /// It was three labelled cells in a tinted box: DISTANCE / TOTAL TIME / + /// ORDERS, each with a glyph and an all-caps caption. That is a dashboard + /// widget, and a dashboard is for comparing many things — there is one stop + /// on this page and it is already finished. The numbers still earn a place + /// (a rider does check what a run cost him) but they earn a line, not a + /// panel. + Widget _metricLine(StopCompliance c) { + final from = stampOf(stop, const [ + OrderEvent.accepted, + OrderEvent.arrivedAtPickup, + 'startedat', + ]); + final done = stampOf(stop, const [ + 'completedat', + 'pickedtime', + 'picked_time', + 'skippedat', + ]); + final took = (from != null && done != null) ? done.difference(from) : null; + final parcels = _parcelCount(); + + final parts = [ + if (c.actualKm != null && c.actualKm! > 0) '${kmText(c.actualKm!)} km', + if (took != null && took.inSeconds > 0) shortDuration(took), + if (parcels != null && parcels > 0) + '$parcels ${parcels == 1 ? 'order' : 'orders'}', + ]; + if (parts.isEmpty) return SizedBox(height: 18.h); + + return Padding( + padding: EdgeInsets.only(top: 14.h, bottom: 22.h), + child: Text( + parts.join(' · '), + textAlign: TextAlign.center, + style: MilerType.micro.copyWith(fontSize: 13.sp), + ), + ); + } + + /// ── Ranked, not removed ── + /// + /// Planned kilometres, ETA kept, bonus points, parcel count, weight, + /// shipper, payment method, order id. Every one of them is a real fact + /// somebody will eventually want, and every one of them was drawn at the + /// same weight as the outcome — four ruled groups of bold values, which is + /// what made a finished delivery read as a form. + /// + /// A rider opening this page is answering *did that go through*. He answers + /// it above the fold and leaves. The office question — *what exactly + /// happened on booking DM-BK-16DA518D* — is asked far less often and by + /// somebody who will happily tap once for it. + /// + /// Closed by default, one open at a time, and a group with nothing in it is + /// not offered at all. + Widget _folded(StopCompliance c) { + final groups = <(String, List)>[ + ('Delivery details', _deliveryInfoRows(c)), + ('Payment', [..._paymentRows(), ..._earningsRows()]), + ('Order details', _orderRows()), + ]; + final live = [ + for (final (title, rows) in groups) + if (rows.whereType().isNotEmpty) (title, rows), + ]; + if (live.isEmpty) return SizedBox(height: 8.h); + + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (i, (title, rows)) in live.indexed) + _DisclosureRow( + title: title, + first: i == 0, + children: rows.whereType().toList(), + ), + ], + ), + ); + } + + /// ── The photograph, at the size a dispute is settled from ── + /// + /// Proof of delivery is the one thing on this record somebody else will ask + /// about, weeks later, when a customer says a parcel never came. A thumbnail + /// is not evidence: it is a coloured rectangle you have to take on trust. So + /// it gets a panel of its own and the full width of it, captioned with the + /// moment it was taken. + /// + /// Absent when the delivery carries no photo — an empty frame saying "no + /// proof" on every older record would be furniture, and the delivery info + /// panel already states what happened. + Widget _proofPanel() { + final path = (stop['proofphotopath'] ?? '').toString().trim(); + if (path.isEmpty) return const SizedBox.shrink(); + final file = File(path); + if (!file.existsSync()) return const SizedBox.shrink(); + + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _sectionLabel('Proof of delivery'), + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: AspectRatio( + aspectRatio: 4 / 3, + child: Image.file( + file, + fit: BoxFit.cover, + // A file that has gone is not proof; the panel disappears + // rather than drawing a broken frame in its place. + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + SizedBox(height: 10.h), + Row( + children: [ + Icon( + LucideIcons.camera, + size: 14.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + 'Taken at the door on this phone', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + SizedBox(height: 14.h), + ], + ), + ); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2. THE TIMELINE — the journey, newest first + // ══════════════════════════════════════════════════════════════════════ + + /// ── Every rung is a real event ── + /// + /// A record carries exactly three stamps — `startedat`, `arrivedat`, + /// `completedat` (see `addCompletedBookings`) — and either of the first two + /// can legitimately be absent: a stop closed in bulk from Home never opened + /// the map, so nothing recorded a departure. + /// + /// **A rung with no timestamp is not drawn.** No greyed-out placeholder rung, + /// no "pending", no invented clock. A crate collected in one gesture at a + /// counter genuinely has one event in its history, and a one-node timeline is + /// the honest picture of that. Same rule [_infoRow] follows for a missing + /// figure, and the same rule [StopCompliance] follows: missing inputs are + /// unknown, not failures. + /// + /// ── Why no timeline package ── + /// + /// `timelines_plus` was a dependency of the Activity screen and was removed. + /// `TimelineTile` centres its node over the whole tile and constrains its + /// contents to the node's extent, which put a dot level with nothing in the + /// middle of an opened record and overflowed the row by ~600pt. The app + /// already owns this idiom in `route_timeline.dart` and on the Activity list + /// itself. A package here would buy a rail and a circle at the cost of the + /// exact bug that was removed to get here. + /// ── One journey, not a tracking list beside a route list ── + /// + /// There were two rails. TRACKING said *when* things happened; ROUTE said + /// *where* they happened; and the rider had to join them himself — read + /// "Picked up 9:18", scroll, read "Vidhya Kitchen", and hold the pairing in + /// his head. Two representations of one journey, drawn in the same visual + /// language, which is what made them look like different subjects when they + /// never were. + /// + /// They are one ladder now. Each place is written onto the rung that + /// happened there — the counter above the collection, the door above the + /// outcome — so time, place and state read as a single line of the story. + Widget _journey(StopStatus status, ({String from, String? to}) ends) => + _timeline(status); + + Widget _timeline(StopStatus status) { + final delivery = stopKindOf(stop).isDelivery; + final ends = routeEndsOf(stop); + + // ── Six moments, and not one of them invented ── + // + // The first comes off the booking (`createdat`, carried through as + // `starttime`); the next four are the rider's own, stamped by the screens + // that did them (see [OrderEvent]); the last is the completion the store + // already wrote. Any of them can legitimately be missing — a record from + // before the ledger existed has only the last three, a stop closed in bulk + // from Home never opened the map — and a moment with no stamp is not drawn. + final assigned = stampOf(stop, const [OrderEvent.assigned]); + final accepted = stampOf(stop, const [OrderEvent.accepted]); + final atPickup = stampOf(stop, const [OrderEvent.arrivedAtPickup]); + final picked = stampOf(stop, const [OrderEvent.pickedUp]); + final released = stampOf(stop, const [OrderEvent.outForDelivery]); + + final started = stampOf(stop, const ['startedat']); + final arrived = stampOf(stop, const ['arrivedat', 'arrivedtime']); + final done = stampOf(stop, const [ + 'completedat', + 'pickedtime', + 'picked_time', + 'skippedat', + ]); + + /// The gap between two moments, when both are known and it is worth saying. + String? gap(DateTime? from, DateTime? to, String tail) { + if (from == null || to == null) return null; + final d = to.difference(from); + return d.inSeconds <= 0 ? null : '${shortDuration(d)} $tail'; + } + + // ── The ladder, and only the ladder ── + // + // Six rungs, in the order a stop actually climbs them: + // + // assigned → accepted → arrived → picked → out for delivery → outcome + // + // Three things that used to have rungs of their own do not any more: + // + // • **Order placed** was the *customer's* event, off the booking's + // `createdat`. It is not something the rider did and it sat at the top + // of his ladder claiming to be step one of his day. + // • **Round started** and **Out for delivery** were two rungs for one act. + // Whichever clock exists is the departure; the other is the same moment + // recorded twice by two screens. + // • **Arrived at customer** folds into the outcome as `5m at the door`. + // It is a duration, and a duration is what the rider is actually asking + // — a seventh rung to say "he got there five minutes before he handed it + // over" is a rung to state a subtraction. + // + // Every one of them is still drawn only if its stamp exists. + final departure = released ?? started; + final points = <_TrackPoint>[ + if (assigned != null) + _TrackPoint( + 'Assigned', + assigned, + gap(assigned, accepted, 'before you took it'), + icon: LucideIcons.mapPinned, + ), + // Suppressed when it and the assignment are one gesture inside a minute: + // a rider who accepts the moment it lands does not need two rungs. + if (accepted != null && + (assigned == null || + accepted.difference(assigned).inMinutes.abs() >= 1)) + _TrackPoint( + 'Accepted', + accepted, + gap(accepted, atPickup, 'to reach'), + icon: LucideIcons.squareCheckBig, + ), + if (atPickup != null || (!delivery && arrived != null)) + _TrackPoint( + 'Arrived', + atPickup ?? arrived, + gap(atPickup ?? arrived, picked ?? done, 'at the counter'), + icon: LucideIcons.store, + // The counter, on the rung that happened at it. + place: ends.from, + address: _pickupAddress(), + ), + // On a collection the pick-up *is* the outcome, so the two would be one + // rung printed twice. The outcome below wins and this one stands down. + if (picked != null && + (done == null || done.difference(picked).inMinutes.abs() >= 1)) + _TrackPoint( + 'Picked up', + picked, + null, + icon: LucideIcons.package, + // Only when no Arrived rung already carried it — the place belongs + // to the visit, and printing it twice makes one stop look like two. + place: (atPickup == null && (delivery || arrived == null)) + ? ends.from + : null, + address: (atPickup == null && (delivery || arrived == null)) + ? _pickupAddress() + : null, + ), + if (departure != null) + _TrackPoint( + delivery ? 'Out for delivery' : 'Set off', + departure, + gap(departure, arrived ?? done, 'on the road'), + icon: LucideIcons.bike, + ), + // ── The outcome carries the door ── + // + // The summary above already says *what* happened, so this rung's note is + // the one fact it does not have: how long the rider stood at the door + // before it did. + if (done != null) + _TrackPoint( + status.label, + done, + delivery ? gap(arrived, done, 'at the door') : null, + icon: status.icon, + // The door. This is what ROUTE's second entry used to say, one + // section further down. + place: delivery ? ends.to : null, + address: delivery ? _dropAddress() : null, + ), + ]; + + // ── The step that never happened ── + // + // A skipped or cancelled stop has a delivery in its story that did not + // occur, and saying so is the point of the record. Drawn as an open ring + // with no clock — the one rung on this page allowed to exist without a + // timestamp, because here the *absence* is the fact rather than a gap in + // what was recorded. Never drawn on a record that completed: there is + // nothing left unreached. + if (status.isSkipped || status.isCancelled || status.isRejected) { + points.insert( + 0, + _TrackPoint( + ServiceProfile.active.completionVerb, + null, + status.isSkipped + ? 'Not completed — return visit owed' + : 'Did not happen', + icon: LucideIcons.minus, + ), + ); + } + + // ── Oldest at the top, and sorted rather than trusted ── + // + // Every order-tracking screen a rider has ever used reads downwards: placed + // at the top, delivered at the bottom, and the eye finishes on the outcome. + // Assembled newest-first here only because that is the order the stamps + // were reached for, so the sort is what actually decides it — two stamps + // written by different screens can land out of sequence (a clock that + // moved, a bulk gesture that beat a poll) and a timeline that renders them + // out of order reads as a bug in the day rather than in the sort. + // ── A rung cannot happen after the record closed ── + // + // Every stamp here is written by a different screen at a different moment, + // and one of them is a *first sighting* rather than an act. A clock that + // lands after the completion it is meant to precede is the timeline + // contradicting itself, so it is dropped rather than drawn out of place. + // The writer that produced them is fixed too — this is the guard, not the + // cure. + final closedAt = done; + if (closedAt != null) { + points.removeWhere( + (p) => + p.at != null && + p.at!.isAfter(closedAt.add(const Duration(minutes: 1))), + ); + } + + final unreached = points.where((p) => p.at == null).toList(); + points + ..removeWhere((p) => p.at == null) + ..sort((a, b) => a.at!.compareTo(b.at!)) + // The step that never happened belongs after everything that did — it is + // where the story stopped, not a moment inside it. + ..addAll(unreached); + + if (points.isEmpty) return const SizedBox.shrink(); + + // ── The outcome is last, and it is the loud one ── + // + // Reading downwards, the eye finishes on the bottom rung, which is where + // the outcome now is. That is the one the record is *about*, so it takes + // the verdict's colour and a wider disc; everything above it is a step that + // got him there and is drawn as done rather than as important. + final int leadIndex = points.length - 1; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('Journey'), + for (var i = 0; i < points.length; i++) + _rung( + points[i], + colour: i == leadIndex ? status.color : ColorConstants.acceptGreen, + lead: i == leadIndex, + first: i == 0, + last: i == points.length - 1, + ), + SizedBox(height: 26.h), + ], + ); + } + + Widget _rung( + _TrackPoint point, { + required Color colour, + required bool lead, + required bool first, + required bool last, + }) { + final bool reached = point.at != null; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── The clock column ── + SizedBox( + // Wide enough for `11:42 AM` at the largest text scale the app + // supports. It was 62 and `maxLines: 1` with no overflow, so the + // two-digit-hour afternoon times silently lost their `AM`/`PM` — + // a clipped clock on a record whose whole point is when things + // happened. + width: 76.w, + child: Padding( + padding: EdgeInsets.only(top: 3.h, right: 10.w), + child: Text( + point.at == null ? '' : clockOf(point.at!), + maxLines: 1, + overflow: TextOverflow.visible, + softWrap: false, + textAlign: TextAlign.right, + style: + MilerType.figure( + 13, + color: lead + ? ColorConstants.slateText + : ColorConstants.secondaryText, + ).copyWith( + fontWeight: lead ? FontWeight.w700 : FontWeight.w600, + letterSpacing: 0, + ), + ), + ), + ), + // ── The rail ── + SizedBox( + width: 30.w, + child: Column( + children: [ + SizedBox( + height: _nodeBox, + child: Stack( + alignment: Alignment.center, + children: [ + // Drawn behind the node in two halves, so the line passes + // through rather than stopping either side of every disc. + Positioned.fill( + child: Column( + children: [ + Expanded( + child: Center( + child: first + ? const SizedBox.shrink() + : _rail(), + ), + ), + Expanded( + child: Center( + child: last ? const SizedBox.shrink() : _rail(), + ), + ), + ], + ), + ), + if (reached) + Container( + width: lead ? 28.w : 24.w, + height: lead ? 28.w : 24.w, + decoration: BoxDecoration( + color: colour, + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants.pureSurface, + width: 2, + ), + boxShadow: lead + ? [ + BoxShadow( + color: colour.withValues(alpha: 0.28), + blurRadius: 10, + spreadRadius: 1, + ), + ] + : null, + ), + child: Icon( + point.icon, + size: lead ? 16.sp : 13.sp, + color: Colors.white, + ), + ) + else + // Never reached. An open ring rather than a filled disc: + // it must not read as a step that is merely pending on a + // record that is already closed. + Container( + width: 22.w, + height: 22.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants.borderStrong, + width: 2, + ), + ), + ), + ], + ), + ), + if (!last) Expanded(child: Center(child: _rail())), + ], + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Padding( + // Bottom padding rather than a gap between rungs: the connector + // runs *through* the space, so it belongs to the rung. + padding: EdgeInsets.only(bottom: last ? 0 : 22.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── The place leads its own rung ── + // + // This is what the separate ROUTE section used to carry. On + // the two rungs that happened somewhere, the somewhere is + // the heading and the act is the line under it — which is + // how the rider thinks it: *at Vidhya Kitchen, I picked up*. + if (point.place != null && + point.place!.trim().isNotEmpty) ...[ + Text( + point.place!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + if (point.address != null && + point.address!.trim().isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + point.address!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.5.sp, + height: 1.35, + ), + ), + ], + SizedBox(height: 5.h), + ], + Text( + point.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + // A rung that names a place has already had its heading; + // the act under it is a normal line. Bold everywhere is + // why this page read heavy. + fontSize: point.place != null + ? 14.sp + : (lead ? 16.sp : 15.sp), + fontWeight: point.place != null + ? FontWeight.w500 + : FontWeight.w600, + letterSpacing: -0.2, + color: reached + ? (point.place != null + ? ColorConstants.secondaryText + : ColorConstants.slateText) + : ColorConstants.secondaryText, + ), + ), + if (point.note != null) ...[ + SizedBox(height: 4.h), + Text( + point.note!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 13.sp, + height: 1.4, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + /// The height a node sits in, at the top of a rung. + /// + /// Tall enough to centre on the first line of a title at every text scale the + /// app supports, and the same on the timeline and the route so the two rails + /// read as one idiom rather than two that nearly line up. + static double get _nodeBox => 34.h; + + /// One length of the rail. Furniture: it says "these are one journey, in + /// order", and must not compete with the marks strung on it. + Widget _rail() => Container(width: 2, color: ColorConstants.borderSubtle); + + // ══════════════════════════════════════════════════════════════════════ + // 3. ROUTE — the two ends, with their addresses + // ══════════════════════════════════════════════════════════════════════ + + /// ``` + /// ● Vidhya Kitchen + /// │ + /// ● Joe Mathew + /// 78, Cross Cut Road, Gandhipuram + /// ``` + /// + /// The same rail idiom as the timeline directly above it, because it is the + /// same journey drawn in space instead of in time — and a second, unrelated + /// layout for the same pair of names would read as a different fact. + /// + /// Each end carries its own address, when the payload has one for it. + /// + /// ── The drop's address was the pickup's ── + /// + /// This read `pickupaddress` **only**, and hung it under `ends.to` — the + /// customer. On a parcel booking that is right by construction: there is one + /// address and the collection point *is* the customer. On a **milk run** the + /// payload carries both, and this printed the kitchen's street under the + /// customer's name — a record whose most checkable fact was wrong, on the one + /// screen that exists to be checked, usually because the office is asking. + /// + /// The precedence is the app's existing one — `_areaOf` in + /// `route_timeline.dart` reads the same five keys in the same order — because + /// two readers answering "where is this going" from different keys is the + /// regression this codebase has already had once, over the source *name*. + /// + /// The pickup line is only drawn when a distinct drop address was found, so + /// nothing here can print the same street twice, and a payload with one + /// address still hangs it exactly where it hung before. + /// ── The ROUTE section is gone; its two addresses moved onto the journey ── + /// + /// These readers stayed, because the fallback they encode is not obvious and + /// is easy to get wrong: with no drop address, the one address the booking + /// carries belongs to the **door**, not the counter. Losing that rule with + /// the section would have put the customer's street under the kitchen. + String _addressAt({required bool drop}) { + String read(List keys) { + for (final k in keys) { + final v = (stop[k] ?? '').toString().trim(); + if (v.isNotEmpty) return v; + } + return ''; + } + + final pickup = read(const ['pickupaddress', 'PickupAddress']); + final delivery = read(const [ + 'dropaddress', + 'DropAddress', + 'deliveryaddress', + ]); + + if (drop) return delivery.isNotEmpty ? delivery : pickup; + return delivery.isNotEmpty ? pickup : ''; + } + + // Compacted, not truncated: the record is the one screen that prints an + // address in full, and on live data "in full" meant the geocoder's whole + // output — a stammered locality, the ward, the state, the country and the + // pincode — cut by an ellipsis exactly where the door was. [compactAddress] + // strips only the boilerplate; every part that names a place survives. + String? _pickupAddress() { + final a = compactAddress(_addressAt(drop: false)); + return a.isEmpty ? null : a; + } + + String? _dropAddress() { + final a = compactAddress(_addressAt(drop: true)); + return a.isEmpty ? null : a; + } + + // ══════════════════════════════════════════════════════════════════════ + // 4. THE MEASUREMENTS + // ══════════════════════════════════════════════════════════════════════ + + /// Distance, duration, ETA, and how the two verdicts came out. + /// + /// **A row with nothing behind it is not drawn.** Every figure here comes + /// from somewhere that can legitimately be empty, and a record with a hole in + /// it can be investigated while one carrying a plausible `0 km` cannot be + /// caught. The one exception is the ETA verdict: `_infoRow` dropping a null + /// would make an unmeasured stop indistinguishable from one whose line had + /// simply not been scrolled to, so that one says `Not measured` out loud. + List _deliveryInfoRows(StopCompliance c) { + final started = stampOf(stop, const ['startedat']); + final done = stampOf(stop, const [ + 'completedat', + 'pickedtime', + 'picked_time', + 'skippedat', + ]); + final took = (started != null && done != null) + ? done.difference(started) + : null; + + final promised = stampOf(stop, const [ + 'etadeadline', + 'expected_pickup_time', + 'expectedpickuptime', + 'slotendtime', + ]); + + return [ + // ── Promised, and actual, on adjacent lines ── + // + // The record could say *whether* the ETA was kept and never *what it + // was*, which is half an answer to the only question the office ever + // asks about a late stop. `etadeadline` is the clock the countdown ran + // to, rescued at completion out of the prefs key the next stop + // overwrites — see `PickupsController.lastEtaDeadline`. + _infoRow('Est. delivery', promised == null ? null : clockOf(promised)), + _infoRow( + stopKindOf(stop).isDelivery ? 'Delivered at' : 'Collected at', + done == null ? null : clockOf(done), + good: c.onTime, + ), + _infoRow( + 'Distance', + c.actualKm == null ? null : '${kmText(c.actualKm!)} km', + ), + _infoRow( + 'Route allowed for', + c.plannedKm == null ? null : '${kmText(c.plannedKm!)} km', + ), + if (c.extraKm != null) + _infoRow('Extra ridden', '+${kmText(c.extraKm!)} km', good: false), + _infoRow('Duration', took == null ? null : shortDuration(took)), + _infoRow('ETA given', plannedEtaOf(stop)), + if (c.onTime != null) + _infoRow( + 'ETA kept', + c.onTime! + ? 'Yes' + : 'Late by ${shortDuration(c.lateBy ?? Duration.zero)}', + good: c.onTime, + ) + else + _infoRow('ETA kept', 'Not measured'), + // ── No completion time here ── + // + // The timeline one section above already states the outcome and the clock + // it happened at, as its head rung. Repeating it as a labelled row gave + // the page two timestamps for one event — and a *label* reading + // "Delivered" sitting a screen under a *status* reading "Delivered", + // which is the same word doing two jobs. + ]; + } + + // ══════════════════════════════════════════════════════════════════════ + // 5. THE ORDER + // ══════════════════════════════════════════════════════════════════════ + + List _orderRows() { + final id = (stop['orderid'] ?? '').toString().trim(); + final kind = stopKindOf(stop); + final proof = stop['proof'] is Map + ? Map.from(stop['proof'] as Map) + : const {}; + final parcels = proof['collected'] ?? proof['handedover']; + final weight = (proof['weight'] ?? stop['weight'] ?? '').toString().trim(); + final shipper = (stop['tenantname'] ?? '').toString().trim(); + + return [ + if (id.isNotEmpty) _orderIdRow(id), + // `Type` is gone. It printed the service profile's own label — `MILK` on + // a meal run — which named the app's internal wiring on a record the + // rider reads to remember a doorstep. The page's own header already says + // whether this was a collection or a hand-over, in words. + + // Weight and parcel counts are captured by the shipment desk, which is a + // capability rather than a line: a run that never prices a shipment never + // weighs one, so these would be two permanent gaps on every meal stop. + // Read the capability, never the tenant. + if (ServiceProfile.active.pricesShipment) ...[ + _infoRow( + kind.isDelivery ? 'Parcels handed over' : 'Parcels collected', + parcels == null ? null : '$parcels', + ), + _infoRow('Weight', weight.isEmpty ? null : '$weight kg'), + ], + if (shipper.isNotEmpty) _infoRow('Shipper', shipper), + ]; + } + + /// The booking reference, and one tap to put it on the clipboard. + /// + /// It is the one field on this screen a rider ever has to reproduce somewhere + /// else — a message to the hub, a support call, a complaint about a stop + /// marked wrong — and copying a twelve-character reference by reading it off + /// one phone screen and typing it into another is exactly the task people get + /// wrong. + Widget _orderIdRow(String id) => _InfoRow( + label: 'Order ID', + valueWidget: Builder( + builder: (context) => Semantics( + button: true, + label: 'Copy order id $id', + child: InkWell( + onTap: () async { + await Clipboard.setData(ClipboardData(text: id)); + if (context.mounted) { + AppFeedback.success(context, 'Order id copied'); + } + }, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 2.h), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Flexible, because a booking reference is not short ── + // + // `#DM-BK-16DA518D-6381` is twenty-one characters and this row + // is a fixed 5/9 of the page. Unconstrained it overflowed by 12 + // pixels and painted the debug stripe over the corner of a + // production screen. + // + // It WRAPS now, where it used to ellipsise. The record is the + // one surface whose job is the full reference — the Activity + // row abbreviates and points here — and an ellipsis at the + // exact character the office is reading out loud made the only + // complete copy in the app incomplete. Two lines bound it; + // the copy control remains for the paste case. + Flexible( + child: Text( + '#$id', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 14.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + ), + ), + ), + SizedBox(width: 6.w), + Icon( + LucideIcons.copy, + size: 14.sp, + color: ColorConstants.secondaryText, + ), + ], + ), + ), + ), + ), + ), + ); + + // ══════════════════════════════════════════════════════════════════════ + // 6. PAYMENT — only on a line that takes money, and only when it did + // ══════════════════════════════════════════════════════════════════════ + + /// Drawn only when money was actually part of this stop. + /// + /// The group used to print "Nothing to collect — prepaid" on every prepaid + /// drop, which is a whole section spent saying a section does not apply — and + /// on a meal run that was every stop of the day. + List _paymentRows() { + if (!ServiceProfile.active.collectsCash) return const []; + + final proof = stop['proof'] is Map + ? Map.from(stop['proof'] as Map) + : const {}; + final due = stopCollectionAmount(stop); + final collected = proof['amountcollected'] is num + ? (proof['amountcollected'] as num).toDouble() + : null; + final method = (proof['paymentmethod'] ?? '').toString().trim(); + + if (due <= 0 && collected == null) return const []; + + return [ + _infoRow('Due at this stop', due > 0 ? rupees(due) : null), + _infoRow( + 'Collected', + collected == null ? null : rupees(collected), + good: collected == null ? null : collected >= due, + ), + _infoRow( + 'Method', + method.isEmpty + ? null + : (method.toLowerCase().contains('upi') ? 'UPI / QR' : 'Cash'), + ), + ]; + } + + // ══════════════════════════════════════════════════════════════════════ + // THE STRIP — three figures, read before anything is read properly + // ══════════════════════════════════════════════════════════════════════ + + /// How many orders this stop carried, or null when the payload does not say. + int? _parcelCount() { + final proof = stop['proof'] is Map + ? Map.from(stop['proof'] as Map) + : const {}; + final n = proof['collected'] ?? proof['handedover']; + if (n != null) return int.tryParse('$n'); + final parcels = stop['parcels']; + if (parcels is List && parcels.isNotEmpty) return parcels.length; + return null; + } + + // ══════════════════════════════════════════════════════════════════════ + // EARNINGS — what this stop was worth, as far as the app actually knows + // ══════════════════════════════════════════════════════════════════════ + + /// ── What is deliberately absent ── + /// + /// **There is no per-stop payout figure anywhere in this app.** `deliver` + /// computes `riderkms` and `ridercharges` server-side and returns neither, + /// and `GET /miler/earnings` answers for a *period*, not a stop. Printing a + /// number here would mean deriving pay from a rate the app does not hold — + /// the one invention on this screen a rider would act on, argue about, and be + /// wrong about. + /// + /// So this section states only what was really recorded against the stop: the + /// bonus the app itself computed at completion, and the cash that changed + /// hands. It is absent entirely when neither happened. The day's actual + /// earnings are on Account, where the figure comes from the server. + List _earningsRows() { + final bonus = int.tryParse( + '${stop['bonuspts'] ?? stop['bonuspoints'] ?? ''}', + ); + final proof = stop['proof'] is Map + ? Map.from(stop['proof'] as Map) + : const {}; + final collected = proof['amountcollected'] is num + ? (proof['amountcollected'] as num).toDouble() + : null; + final takesCash = ServiceProfile.active.collectsCash; + + return [ + if (bonus != null && bonus > 0) + _InfoRow(label: 'On-time bonus', value: '$bonus pts', good: true), + if (takesCash && collected != null && collected > 0) + _InfoRow(label: 'Cash collected', value: rupees(collected)), + // Said out loud rather than left as an absence, because "what did I make + // on that one" is a question this screen looks like it should answer. + if ((bonus == null || bonus <= 0) && + (!takesCash || collected == null || collected <= 0)) + const _InfoRow(label: 'Stop payout', value: 'See Account'), + ]; + } + + // ══════════════════════════════════════════════════════════════════════ + // SHARED FURNITURE + // ══════════════════════════════════════════════════════════════════════ + + /// ── A section head, with a mark ── + /// + /// Five grey eyebrows down a page all look like the same word, and the eye + /// stops using them to navigate. A 3pt brand-coloured rule in front of each + /// one gives the run of sections a repeating left mark to jump between — the + /// cheapest possible signpost, and the only place on this page the brand + /// colour appears outside a control. + Widget _sectionLabel(String title) => Padding( + padding: EdgeInsets.only(bottom: 14.h), + child: Row( + children: [ + Container( + width: 3.w, + height: 13.h, + decoration: BoxDecoration( + color: ColorConstants.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + SizedBox(width: 8.w), + // Flexible: a letterspaced eyebrow at 2.0x system text is wider than + // a 320pt panel minus its rim, and a rigid Row pays that in an + // overflow stripe rather than an ellipsis. + Flexible( + child: Text( + title.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.eyebrow.copyWith( + fontSize: 11.5.sp, + letterSpacing: 1.0, + ), + ), + ), + ], + ), + ); + + /// `Distance 4.6 km` + /// + /// Returns null for an unknown value so [_section] can drop a whole group + /// that turned out to have nothing in it. + Widget? _infoRow(String label, String? value, {bool? good}) { + if (value == null || value.trim().isEmpty) return null; + return _InfoRow(label: label, value: value, good: good); + } +} + +/// One label→value line. +/// +/// ── The record was set at footnote sizes ── +/// +/// Labels ran at 12.5sp and values at 11.5. That is fine on a desk and +/// unreadable on a bike in daylight, which is the only place this page is ever +/// opened — a rider comes here precisely because he needs to *read* something. +/// 14 for the label, 14.5 for the value it names; nothing here is decoration, +/// so nothing here is smaller than body text on any other screen. +class _InfoRow extends StatelessWidget { + final String label; + final String? value; + final Widget? valueWidget; + final bool? good; + + const _InfoRow({ + required this.label, + this.value, + this.valueWidget, + this.good, + }); + + @override + Widget build(BuildContext context) { + final Color valueColor = good == null + ? ColorConstants.slateText + : good! + ? ColorConstants.acceptGreen + : ColorConstants.warning; + + return Padding( + padding: EdgeInsets.symmetric(vertical: 8.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 4, + child: Text( + label, + style: MilerType.micro.copyWith(fontSize: 14.sp), + ), + ), + SizedBox(width: 10.w), + Expanded( + flex: 5, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: + valueWidget ?? + Text( + value!, + textAlign: TextAlign.right, + maxLines: 2, + overflow: TextOverflow.ellipsis, + // ── Bold is for the exception, not for every value ── + // + // Every value on this page was w700 — the clock, the + // kilometres, "Not measured", "See Account", the order id. + // When everything is emphasised nothing is, and it was one + // of the two reasons the record read heavy after the boxes + // came off it. + // + // Medium by default. A row that has been *tinted* keeps + // the weight, because a tint is only ever applied to + // something that went wrong or to money, and both of those + // are meant to be noticed. + style: MilerType.micro.copyWith( + fontSize: 14.sp, + // `good` is what makes a value an exception — a kept + // ETA or a missed one. Those keep the weight; the + // ordinary rows are medium. + fontWeight: good == null + ? FontWeight.w500 + : FontWeight.w600, + color: valueColor, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// One event on the tracking timeline. +/// +/// [at] is non-null by construction — [DeliveryDetailsPage._timeline] only +/// builds a rung for a stamp that exists. +class _TrackPoint { + final String title; + + /// When it happened, or null for a step this record never reached. + /// + /// Only a *closed* record draws an unreached step, and only where its absence + /// is itself the fact — see [DeliveryDetailsPage._timeline]. Nothing that + /// merely went unrecorded gets a rung. + final DateTime? at; + + /// At most one short line under the title, and only when it says something + /// the title does not. Null is the common case. + final String? note; + + /// The glyph in this rung's node. + /// + /// Per event, not one tick everywhere. A rail of identical ticks says only + /// "these all happened", which the rail itself already says by being solid — + /// and a rider scanning for the moment he took the bag is looking for a + /// *shape*, not reading six titles. The outcome's glyph is [StopStatusX]'s, + /// so it matches the tile on the card that opened this page. + final IconData icon; + + /// The place this rung happened at, when it is one of the journey's two + /// ends. Null on the rungs that are moments rather than locations — + /// *Accepted* happened wherever the rider was standing, and inventing a + /// place for it would be inventing a fact. + final String? place; + + /// Its street address, under the name. Same rule: absent, not empty. + final String? address; + + /// [note] is positional and nullable because every rung decides it inline — + /// most are a computed gap and most of those are null. [icon] is required so + /// a new rung cannot be added without somebody choosing what it looks like. + const _TrackPoint( + this.title, + this.at, + this.note, { + required this.icon, + this.place, + this.address, + }); +} + +/// One folded group of facts — `Payment ›`, opening to its rows. +/// +/// ── Why this is not an ExpansionTile ── +/// +/// Material's opens with a leading rotation, a divider it draws itself and a +/// tile shape that fights the page's own rhythm; every one of those had to be +/// overridden, and what survives the overriding is this. It also collapses +/// with an unbounded-height animation that a `ListView` re-lays-out through, +/// which is what makes long lists stutter mid-open on a mid-range phone. +/// +/// Restrained on purpose. The row is a title, a chevron and a hairline; the +/// body reveals with the app's own state duration and nothing else moves. +class _DisclosureRow extends StatefulWidget { + final String title; + final List children; + + /// The first row draws its own top rule, so the group reads as a block + /// rather than as three things that happen to be adjacent. + final bool first; + + const _DisclosureRow({ + required this.title, + required this.children, + required this.first, + }); + + @override + State<_DisclosureRow> createState() => _DisclosureRowState(); +} + +class _DisclosureRowState extends State<_DisclosureRow> { + bool _open = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.first) + Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle), + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _open = !_open), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.h), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: MilerType.body.copyWith( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + ), + ), + AnimatedRotation( + turns: _open ? 0.25 : 0, + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + child: Icon( + LucideIcons.chevronRight, + size: 17.sp, + color: ColorConstants.secondaryText, + ), + ), + ], + ), + ), + ), + ), + ClipRect( + child: AnimatedSize( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _open + ? Padding( + padding: EdgeInsets.only(bottom: 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.children, + ), + ) + : const SizedBox(width: double.infinity, height: 0), + ), + ), + Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle), + ], + ); + } +} diff --git a/lib/views/Dashboard/home/accepted_pill.dart b/lib/views/Dashboard/home/accepted_pill.dart index 08fd9e5..45707ad 100644 --- a/lib/views/Dashboard/home/accepted_pill.dart +++ b/lib/views/Dashboard/home/accepted_pill.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; @@ -17,7 +19,7 @@ import 'package:miler/widget/Bottom_page.dart'; /// three stops and scrolled back to the top had nothing on screen telling him /// so, and no way through. /// -/// This says both things in one control, in one fixed place: **how many +/// This says both things in one control, in one fixed p~lace: **how many /// bookings he is holding**, and one tap to go work them. It is the counterpart /// to [SelectionBar] — that one is about the stops he is choosing, this one /// about the stops he has already taken — so they share the floating slot at @@ -29,6 +31,10 @@ import 'package:miler/widget/Bottom_page.dart'; /// at some point today". /// ───────────────────────────────────────────────────────────────────────── class AcceptedPill extends StatelessWidget { + /// The tallest this pill gets, excluding the device inset. See + /// [SelectionBar.maxHeight] for why the surface declares its own. + static double get maxHeight => ButtonSizes.compact + 24.h; + /// Accepted bookings still waiting to be worked. Zero renders nothing. final int count; @@ -41,6 +47,8 @@ class AcceptedPill extends StatelessWidget { Widget build(BuildContext context) { if (count <= 0) return const SizedBox.shrink(); + final profile = ServiceProfile.active; + final radius = BorderRadius.circular(DesignConstants.radiusFull); // Rides the floating nav bar: it is `Positioned` outside the Scaffold's @@ -64,11 +72,29 @@ class AcceptedPill extends StatelessWidget { alignment: Alignment.centerRight, child: Semantics( button: true, + // The noun follows the rider's line of work — "booking" on a + // parcel route, "delivery" on a meal run. See [ServiceProfile]. label: count == 1 - ? '1 accepted booking. Go to Bookings.' - : '$count accepted bookings. Go to Bookings.', + ? '1 ${profile.jobNoun} ${_verb(profile)}. ' + 'Go to ${profile.workTabLabel}.' + : '$count ${profile.jobNounPlural} ${_verb(profile)}. ' + 'Go to ${profile.workTabLabel}.', + // ── Red is for the action, not for the notice ── + // + // This was a solid brand-red slab with a white disc in it, floating + // over the foot of the route. On a page whose one accent moment is + // supposed to be the journey button, it was the largest and + // reddest object on the screen — and it is not an action the screen + // exists for at all. It is a *notice with a way through*: "you are + // holding nine of these, they are over there." + // + // So the surface goes quiet — the same lifted white the selection + // bar uses, which is the app's one "floating over the list" + // material — and the brand is spent on the two things that carry + // the meaning: the count and the arrow out. Same height, same + // wording, a third of the ink. child: Material( - color: ColorConstants.primary, + color: ColorConstants.pureSurface, borderRadius: radius, elevation: 0, child: InkWell( @@ -78,10 +104,11 @@ class AcceptedPill extends StatelessWidget { // 48 clears the Material touch-target floor without the pill // reading as a primary CTA. height: 48.h, - padding: EdgeInsets.fromLTRB(8.w, 0, 14.w, 0), + padding: EdgeInsets.fromLTRB(7.w, 0, 10.w, 0), decoration: BoxDecoration( + color: ColorConstants.pureSurface, borderRadius: radius, - boxShadow: DesignConstants.shadowLg, + boxShadow: DesignConstants.shadowFloat, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -90,41 +117,52 @@ class AcceptedPill extends StatelessWidget { // is the thing the rider is checking from across the // handlebars, and it changes while the words do not. Container( - width: 32.w, - height: 32.w, + width: 30.w, + height: 30.w, alignment: Alignment.center, decoration: BoxDecoration( - color: ColorConstants.pureSurface, + color: ColorConstants.primary, shape: BoxShape.circle, ), child: Text( '$count', style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w800, + fontSize: 14.sp, + fontWeight: FontWeight.w700, height: 1, letterSpacing: -0.3, - color: ColorConstants.primary, + color: ColorConstants.onAccent, fontFamily: FontConstants.fontFamily, ), ), ), SizedBox(width: 9.w), Text( - count == 1 ? 'booking accepted' : 'bookings accepted', + // ── The word has to match the boundary ── + // + // It said "accepted" on both lines. On a round the + // count is `WorkBoundary.deliveryQueue` — work already + // **collected** and waiting to be delivered — so a + // rider holding twenty-one bags read "21 deliveries + // accepted", which is the wrong verb for the wrong + // rung and understates what he is carrying. + // + // Logistics hands over at acceptance, so there the word + // is right. Same knob the boundary itself reads. + _waiting(profile, count), style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.1, - color: Colors.white, + color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(width: 2.w), Icon( - Icons.chevron_right_rounded, + LucideIcons.chevronRight, size: 20.sp, - color: Colors.white, + color: ColorConstants.primary, ), ], ), @@ -136,4 +174,19 @@ class AcceptedPill extends StatelessWidget { ), ); } + + /// The verb for the rung this count actually sits on. + /// + /// The pill counts `WorkBoundary.deliveryQueue`, and where that boundary sits + /// is a property of the line — [ServiceProfile.handoffAt]. On a round it is + /// **pickup-complete**, so the work is in the rider's hands; on logistics it + /// is acceptance, so "accepted" is exactly right. One knob, read here rather + /// than a line-name check. + static String _verb(ServiceProfile profile) => + profile.handsOffAtCollection ? 'in hand' : 'accepted'; + + /// `deliveries in hand` · `bookings accepted`. + static String _waiting(ServiceProfile profile, int count) => + '${count == 1 ? profile.jobNoun : profile.jobNounPlural} ' + '${_verb(profile)}'; } diff --git a/lib/views/Dashboard/home/duty_sheet.dart b/lib/views/Dashboard/home/duty_sheet.dart index d932c86..fce209b 100644 --- a/lib/views/Dashboard/home/duty_sheet.dart +++ b/lib/views/Dashboard/home/duty_sheet.dart @@ -1,41 +1,39 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:slide_to_submit_button/slide_to_submit_button.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; -import 'package:miler/views/helpers/widgets/miler_app_bar.dart' show milerGlassSheet; -import 'package:miler/views/helpers/widgets/page_transitions.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; /// ───────────────────────────────────────────────────────────────────────── /// GOING ON / OFF DUTY /// -/// The confirmation behind the duty switch in the app bar. It used to be a -/// white slab with a 32pt red ✕ floating above a 88pt red circle holding a -/// **wifi** glyph, under the heading "Go Online?" and one grey line — "You -/// will stop receiving bookings". +/// One decision, asked once. /// -/// Three things were wrong with that, and none of them was the styling: +/// ── What this had accumulated ── /// -/// • **Wifi is the wrong idea.** Duty is not connectivity. A rider reading a -/// wifi symbol on a screen that stops his work is being told the app has a -/// network problem. -/// • **It did not say what changes.** "You will stop receiving bookings" is -/// one consequence of three. Live tracking stops, and — the one that -/// actually catches people out — the app will refuse to take him off duty -/// while a booking is still open. He found that out *after* sliding. -/// • **It was the only sheet in the app that looked like this.** Every other -/// sheet is frosted glass with a grab handle and a small round close -/// button; see [milerGlassSheet]. +/// A grab handle, a tinted icon disc, a title, a subtitle, a **drawn diagram of +/// the switch moving from one state to the other**, three icon-and-text rows +/// naming the consequences, and a slide-to-confirm. Eight blocks for a question +/// with two answers — the shape of a screen assembled from good intentions one +/// at a time rather than designed. /// -/// So: the app's own sheet chrome, the duty glyph the rest of the app uses, -/// a strip showing the switch moving from one state to the other, and the -/// consequences named one per line instead of summarised in a sentence. +/// Each piece was individually defensible and the total was not. The diagram +/// illustrated a state change the title had already stated. The consequence +/// rows were three lines of icons for facts that fit in one sentence. And the +/// slide was borrowed from the payment flows, where the app's own rule is that +/// **slides are reserved for money** — a rider ending his shift is not +/// confirming a transaction, and asking for a drag rather than a tap made the +/// commonest action of his day the most laborious. /// -/// The slide-to-confirm survives both directions. Going off duty mid-shift is -/// worth a deliberate gesture, and a rider whose thumb is wet from the rain -/// should not be able to end his day with a stray tap. +/// ── What it is ── +/// +/// The question, one sentence of consequence, and two answers with an obvious +/// primary. Nothing is illustrated that is also written. Going off duty is the +/// consequential direction, so it carries the brand and states the one thing +/// that genuinely catches riders out — the app will not let him off duty while +/// a booking is open — in the sentence rather than after a failed gesture. /// ───────────────────────────────────────────────────────────────────────── class DutySheet extends StatelessWidget { /// True when this sheet is asking to go **on** duty. @@ -64,15 +62,9 @@ class DutySheet extends StatelessWidget { required bool goingOnline, required Future Function() onConfirm, }) { - return showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - // The sheet paints its own frosted shape — a white sheet behind it would - // show as a square corner behind the rounded one. - backgroundColor: Colors.transparent, - builder: (_) => - DutySheet(goingOnline: goingOnline, onConfirm: onConfirm), + return showMilerSheet( + context, + builder: (_) => DutySheet(goingOnline: goingOnline, onConfirm: onConfirm), ); } @@ -87,395 +79,116 @@ class DutySheet extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - top: false, - left: false, - right: false, - bottom: true, - child: milerGlassSheet( - child: Padding( - padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40.w, - height: 4.h, - decoration: BoxDecoration( - color: ColorConstants.borderSubtle, - borderRadius: BorderRadius.circular( - DesignConstants.radiusLg, - ), - ), - ), - ), - SizedBox(height: 16.h), - _header(context), - SizedBox(height: 18.h), - // What the switch is about to do, drawn rather than described. - _TransitionStrip(goingOnline: goingOnline, accent: _accent), - SizedBox(height: 16.h), - ..._consequences(), - SizedBox(height: 20.h), - _slider(context), - ], - ), - ), - ), - ); - } - - Widget _header(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // The duty glyph, not a wifi bar. Same icon the "Go on duty" button on - // an empty trip carries, so one idea keeps one symbol. - Container( - padding: EdgeInsets.all(10.r), - decoration: BoxDecoration( - color: _accent.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Icon( - Icons.power_settings_new_rounded, - color: _accent, - size: 20.sp, - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - goingOnline ? 'Go on duty?' : 'Go off duty?', - style: TextStyle( - fontSize: 19.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.slateText, - ), - ), - SizedBox(height: 2.h), - Text( - goingOnline - ? 'Your hub can start assigning you trips again.' - : 'Your hub stops assigning you trips until you are back.', - style: TextStyle( - fontSize: 12.5.sp, - height: 1.35, - fontWeight: FontWeight.w600, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.secondaryText, - ), - ), - ], - ), - ), - SizedBox(width: 10.w), - // The same small round dismiss every other sheet uses, in place of the - // 32pt red ✕ that used to sit alone on its own row above everything. - InkWell( - onTap: () => Navigator.of(context).maybePop(), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - child: Container( - padding: EdgeInsets.all(8.r), - decoration: BoxDecoration( - color: ColorConstants.neutralLight, - shape: BoxShape.circle, - ), - child: Icon( - Icons.close_rounded, - color: ColorConstants.slateText, - size: 18.sp, - ), - ), - ), - ], - ); - } - - /// One line per thing that actually changes. - /// - /// The third off-duty line is the one the old sheet withheld: the app blocks - /// the switch while a booking is still open, and it used to say so only - /// after the rider had completed the slide and watched it bounce back. - List _consequences() { - final rows = goingOnline - ? const [ - (Icons.alt_route_rounded, 'Your hub can add you to the next slot'), - ( - Icons.my_location_rounded, - 'Live tracking runs while you are on duty', - ), - ] - : const [ - ( - Icons.notifications_paused_rounded, - 'No new trips until you are back on', - ), - (Icons.location_off_rounded, 'Live tracking pauses'), - ( - Icons.inventory_2_rounded, - 'Any booking you are holding must be finished first', - ), - ]; - - return [ - for (var i = 0; i < rows.length; i++) ...[ - if (i > 0) SizedBox(height: 10.h), - _ConsequenceRow( - icon: rows[i].$1, - label: rows[i].$2, - accent: _accent, - ), - ], - ]; - } - - Widget _slider(BuildContext context) { - final label = goingOnline ? 'Slide to go on duty' : 'Slide to go off duty'; - - return SlideToSubmit.custom( - height: 58, - sliderWidth: 46, - padding: const EdgeInsets.all(6), - // The track is the accent at low alpha, so the control is the same colour - // as the decision. It was a flat grey slab that gave no clue which of the - // two things it was about to do. - backgroundDecoration: BoxDecoration( - color: _accent.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - ), - foregroundDecoration: const BoxDecoration(color: Colors.transparent), - slider: Center( - child: Container( - height: 46, - width: 46, - alignment: Alignment.center, - decoration: BoxDecoration( - // A filled knob in the accent, not a white disc with a grey - // chevron: on a tinted track the white one read as the empty part. - color: _accent, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: _accent.withValues(alpha: 0.30), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: Icon( - Icons.arrow_forward_rounded, - size: 20.sp, - color: ColorConstants.onAccent, - ), - ), - ), - // Words, not just a drifting arrow. The old hint was an arrow asset and - // nothing else, which says "something slides" without saying what - // happens when it lands. - hint: IgnorePointer( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(width: 46.0 + 8.w), - Expanded( - child: Text( - label, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - fontFamily: FontConstants.fontFamily, - color: _accent, - ), - ), - ), - Padding( - padding: EdgeInsets.only(right: 14.w), - // Boxed and clipped. The arrow animates in from `Offset(-2, 0)` — - // two of its own widths to the left — so left to itself it drifts - // straight across the label and prints an arrow through the - // middle of the words. Its own box turns that into what it was - // meant to be: an arrow sliding in at the end of the track. - child: SizedBox( - width: 26.w, - child: ClipRect( - // The asset is a fixed dark glyph, so it is tinted rather - // than dropped: the track is coloured now, and a black arrow - // on a green track was the one thing on the control that did - // not belong to it. - child: ColorFiltered( - colorFilter: ColorFilter.mode( - _accent.withValues(alpha: 0.55), - BlendMode.srcIn, - ), - child: const AnimatedSlideArrow( - arrowImage: AssetImage( - 'assets/arrow_right.png', - package: 'slide_to_submit_button', - ), - ), - ), - ), - ), - ), - ], - ), - ), - // The knob springs back whatever happens, and the sheet closes only when - // duty actually changed — a failed API call leaves the rider looking at - // the decision he was trying to make, not at a screen that has silently - // dropped it. - onSubmit: (controller) async { - final ok = await onConfirm(); - try { - controller.reset(); - } catch (_) {} - if (ok && context.mounted) Navigator.of(context).maybePop(); - }, - ); - } -} - -/// `Off duty ──→ On duty`, as the two states of the switch that opened this. -/// -/// A confirmation should show the change, not only name it. The state he is -/// leaving stays grey and the one he is heading for takes the accent, so which -/// direction this sheet runs in is legible before a word is read. -class _TransitionStrip extends StatelessWidget { - final bool goingOnline; - final Color accent; - - const _TransitionStrip({required this.goingOnline, required this.accent}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _StateChip( - label: goingOnline ? 'Off duty' : 'On duty', - color: ColorConstants.secondaryText, - filled: false, - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 10.w), - child: Icon( - Icons.arrow_forward_rounded, - size: 16.sp, - color: ColorConstants.borderStrong, - ), - ), - Expanded( - child: _StateChip( - label: goingOnline ? 'On duty' : 'Off duty', - color: accent, - filled: true, - ), - ), - ], - ); - } -} - -class _StateChip extends StatelessWidget { - final String label; - final Color color; - - /// The destination state. The one being left is drawn hollow. - final bool filled; - - const _StateChip({ - required this.label, - required this.color, - required this.filled, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 12.w), - decoration: BoxDecoration( - color: filled ? color.withValues(alpha: 0.12) : Colors.transparent, - border: Border.all( - color: filled ? Colors.transparent : ColorConstants.borderStrong, - ), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + // ── The SafeArea used to wrap the GLASS ── + // + // So the frosted surface stopped at the top of the gesture inset and the + // brand-red home indicator area showed through beneath it — a coloured + // stripe under every duty change. The kit pads *inside* the fabric. + return MilerSheetScaffold( + padding: EdgeInsets.fromLTRB(22.w, 0, 22.w, 18.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - SizedBox(width: 8.w), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - fontFamily: FontConstants.fontFamily, - color: filled ? color : ColorConstants.secondaryText, - ), - ), - ), - ], - ), - ); - } -} + SizedBox(height: 6.h), -class _ConsequenceRow extends StatelessWidget { - final IconData icon; - final String label; - final Color accent; - - const _ConsequenceRow({ - required this.icon, - required this.label, - required this.accent, - }); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(top: 1.h), - child: Icon(icon, size: 17.sp, color: accent.withValues(alpha: 0.75)), - ), - SizedBox(width: 10.w), - Expanded( - child: Text( - label, + Text( + goingOnline ? 'Go on duty?' : 'Go off duty?', style: TextStyle( - fontSize: 13.sp, - height: 1.35, - fontWeight: FontWeight.w600, + fontSize: 21.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, fontFamily: FontConstants.fontFamily, color: ColorConstants.slateText, ), ), + SizedBox(height: 8.h), + + // One sentence. The off-duty version carries the fact that used + // to be discovered only after a failed slide. + Text( + goingOnline + ? 'Your hub can add you to the next slot, and live ' + 'tracking runs while you are on.' + : 'Your hub stops assigning you trips and live tracking ' + 'pauses. Any booking you are holding has to be ' + 'finished first.', + style: TextStyle( + fontSize: 14.5.sp, + height: 1.45, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.secondaryText, + ), + ), + SizedBox(height: 24.h), + + _confirm(context), + SizedBox(height: 4.h), + _dismiss(context), + ], + ), + ); + } + + /// The answer, as a button. Green to start the day, brand to end it — the + /// same two colours the duty switch itself uses, so the sheet is visibly + /// about the control that opened it. + Widget _confirm(BuildContext context) { + return SizedBox( + height: ButtonSizes.primary, + child: ElevatedButton( + onPressed: () async { + final changed = await onConfirm(); + if (changed && context.mounted) Navigator.of(context).maybePop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: _accent, + foregroundColor: ColorConstants.onAccent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), ), - ], + child: Text( + goingOnline ? 'Go on duty' : 'Go off duty', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.onAccent, + ), + ), + ), + ); + } + + /// The way out, stated as what it leaves him as rather than as "Cancel" — a + /// rider reading two buttons should not have to work out which one is the + /// no-op. + Widget _dismiss(BuildContext context) { + return SizedBox( + height: ButtonSizes.secondary, + child: TextButton( + onPressed: () => Navigator.of(context).maybePop(), + style: TextButton.styleFrom( + foregroundColor: ColorConstants.secondaryText, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + ), + child: Text( + goingOnline ? 'Not yet' : 'Stay on duty', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.secondaryText, + ), + ), + ), ); } } diff --git a/lib/views/Dashboard/home/homepage.dart b/lib/views/Dashboard/home/homepage.dart index b64d838..0e9751e 100644 --- a/lib/views/Dashboard/home/homepage.dart +++ b/lib/views/Dashboard/home/homepage.dart @@ -1,6 +1,9 @@ // ignore_for_file: unused_element, unused_import +import 'package:animated_toggle_switch/animated_toggle_switch.dart'; +import 'package:another_flushbar/flushbar.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:lottie/lottie.dart' hide Marker; import 'dart:convert'; @@ -14,12 +17,12 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:miler/providers/pickup/pickup_provider.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/views/Dashboard/orders/orderstaus_button.dart'; -import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; import 'package:miler/views/helpers/constants/apiconstants.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/helpers/poll_policy.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; @@ -27,7 +30,7 @@ import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/controllers/riderlog.dart'; import 'package:miler/controllers/logcontroller.dart'; import 'package:miler/controllers/duty_controller.dart'; -import 'package:miler/models/stop_status.dart'; +import 'package:miler/Models/stop_status.dart'; import 'package:miler/controllers/pickup.dart'; import 'package:miler/controllers/pickups_controller.dart'; import 'package:miler/controllers/summary_controller.dart'; @@ -40,7 +43,6 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:floating/floating.dart'; import 'dart:math' as math; import 'package:http/http.dart' as http; -import 'package:miler/views/Dashboard/home/homepage_banner.dart'; import 'package:miler/views/Dashboard/home/accepted_pill.dart'; import 'package:miler/views/Dashboard/home/selection_bar.dart'; import 'package:miler/views/Dashboard/home/route_brief.dart'; @@ -55,9 +57,21 @@ import 'package:miler/views/Dashboard/pickups/pickups.dart' as pickups; import 'package:miler/background/live_tracking_service.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/api_config.dart'; +import 'package:miler/data/consignment_state.dart'; import 'package:miler/views/Dashboard/pickups/collect_payment.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/widget/Bottom_page.dart'; +import 'package:miler/data/meal_run_mock.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/order_events.dart'; +import 'package:miler/data/work_domain.dart'; +import 'package:miler/data/work_repository.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; +import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; +import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; /// Helper function to launch phone dialer Future launchPhoneDialer(String phoneNumber) async { @@ -191,6 +205,12 @@ class _HomepageState extends State /// immediately so a slow network can never bounce a stop back to pending /// under his thumb. Set _acceptedIds = {}; + + /// Orders the rider is physically carrying, on a service run. + /// + /// Local only — the parcel backend has no state between "accepted" and + /// "picked", and `picked` is the delivery. See the collected store. + Set _collectedIds = {}; Set _rejectedIds = {}; /// Which trip tab is open. @@ -459,65 +479,31 @@ class _HomepageState extends State } void _showLocationBottomSheet() { - showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => Padding( - padding: EdgeInsets.only( - left: 24, - right: 24, - top: 20, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, - ), + // ── Through the sheet kit ── + // + // This drew a plain white sheet with its own 24 radius, an oversized 64pt + // icon in a filled disc, and a Close IconButton row — the one sheet in the + // app with an X, duplicating both the barrier tap and the drag the kit's + // handle advertises. The header idiom is the same one every other sheet + // now uses; the icon is the standard badge size, and the drag handle plus + // the barrier are how a sheet closes. + showMilerSheet( + context, + builder: (ctx) => MilerSheetScaffold( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - const Spacer(), - IconButton( - tooltip: 'Close', - icon: Icon(Icons.close, color: _textSecondary), - onPressed: () => Navigator.of(ctx).pop(), - ), - ], + const MilerSheetHeader( + title: 'Turn on location', + subtitle: + 'Bookings and live tracking need your position. Nothing is ' + 'assigned to a rider the hub cannot see.', + icon: LucideIcons.mapPinOff, ), - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: _lightRed, - shape: BoxShape.circle, - ), - child: Icon(Icons.location_off, size: 64, color: _primaryRed), - ), - const SizedBox(height: 16), - Text( - "Location Services Required", - style: TextStyle( - fontSize: FontConstants.xxLarge(context), - fontWeight: FontWeight.bold, - fontFamily: FontConstants.fontFamily, - color: _textPrimary, - ), - ), - const SizedBox(height: 8), - Text( - "Please enable location services to receive bookings and track pickups.", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: FontConstants.regular(context), - fontFamily: FontConstants.fontFamily, - color: _textSecondary, - ), - ), - const SizedBox(height: 24), + SizedBox(height: 20.h), MilerButton( - label: "Enable location", - color: _primaryRed, + label: 'Enable location', onPressed: () async { Navigator.of(ctx).pop(); await Geolocator.openLocationSettings(); @@ -531,6 +517,26 @@ class _HomepageState extends State // ==================== PROXIMITY METHODS ==================== Future _isNearPickupLocation(Map Booking) async { + // ── The second proximity gate ── + // + // `PickupsController._checkGeofence` guards a single stop against the + // rider's configured `pickupradius`. This one is separate: it guards the + // *bulk* "mark selected as arrived" action on Home against a hardcoded + // 500 m, and it runs before the controller is ever called. + // + // So it has to honour the same switch, or turning the fence off still + // leaves a rider blocked here with a different message and no obvious + // reason why one path let him through and the other did not. That switch is + // currently OFF — see [kGeofenceEnforced] for why and how to restore it. + if (kBypassGeofenceForTesting) { + debugPrint( + '[GEOFENCE] OFF for bulk arrival — enforcement is disabled in this ' + 'build. Stop completion is NOT proximity-verified. ' + 'See kGeofenceEnforced.', + ); + return true; + } + try { final pickupLat = _parseDouble( Booking['pickuplat'] ?? Booking['PickupLat'], @@ -578,17 +584,19 @@ class _HomepageState extends State context: context, barrierDismissible: true, builder: (ctx) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(DesignConstants.radiusXl)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), title: Row( children: [ - Icon(Icons.location_off, color: _primaryRed, size: 28), + Icon(LucideIcons.mapPinOff, color: _primaryRed, size: 28), const SizedBox(width: 12), Expanded( child: Text( 'Not at Pickup Location', style: TextStyle( fontSize: FontConstants.xLarge(context), - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), @@ -613,7 +621,7 @@ class _HomepageState extends State 'OK', style: TextStyle( fontSize: FontConstants.regular(context), - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -673,7 +681,7 @@ class _HomepageState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textPrimary, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), ), content: Text( @@ -981,9 +989,7 @@ class _HomepageState extends State // Started first so the work runs *inside* the floor, not after it. // 650 → 400: still long enough to read as a real refresh rather than a // flicker, 250ms cheaper on every pull. - final minimumSpin = Future.delayed( - const Duration(milliseconds: 400), - ); + final minimumSpin = Future.delayed(const Duration(milliseconds: 400)); try { // Wait out a poll already in flight. Bounded, so a hung request cannot @@ -1061,9 +1067,13 @@ class _HomepageState extends State // Home tab. final acceptedIds = await getAcceptedOrderIds(); final rejectedIds = await getRejectedOrderIds(); + final collectedIds = await getCollectedOrderIds(); // Stops parked for a return visit. They are Activity's now — see the // stamping loop below and `TripCard._showsOnHome`. final skippedIds = await getSkippedOrderIds(); + // Stops he has already closed today. Same argument as the skips: the + // rider's own record of what he did beats a queue that has not caught up. + final completedIds = await getCompletedOrderIds(); // Accepted stops have left the Home list for the Bookings tab, but they // are still on the rider's route — their parcels are still to be @@ -1104,7 +1114,13 @@ class _HomepageState extends State final id = (s['orderid'] ?? '').toString(); if (id.isEmpty) continue; final raw = stopStatusFromRaw(s['orderstatus']); - if (skippedIds.contains(id)) { + // Finished beats everything: a stop he has closed is closed, whatever + // the queue still says about it. Without this the row he just picked + // up stays on Home wearing its LIVE mark until the backend catches up, + // which reads as the confirm not having worked. + if (completedIds.contains(id)) { + s['orderstatus'] = 'picked'; + } else if (skippedIds.contains(id)) { s['orderstatus'] = 'skipped'; } else if (raw == StopStatus.skipped && acceptedIds.contains(id)) { s['orderstatus'] = 'accepted'; @@ -1134,7 +1150,8 @@ class _HomepageState extends State // Visibility into the pipeline so a "API returned N but Home shows 0" can // be diagnosed from logs (online flag, raw count, and survivors). debugPrint( - '[FETCH_QUEUES] online=$online rawFromApi=${items.length} ' + '[FETCH_QUEUES] line=${ServiceProfile.active.line.name} ' + 'online=$online rawFromApi=${items.length} ' 'afterFilters=${validOrders.length} ' 'hidden=${_hiddenOrderIds.length} accepted=${acceptedIds.length} ' 'rejected=${rejectedIds.length}', @@ -1172,6 +1189,7 @@ class _HomepageState extends State _allTripStops = allStops; _acceptedIds = acceptedIds; _rejectedIds = rejectedIds; + _collectedIds = collectedIds; _ordersMap = newOrdersMap; _orderIds = newOrderIds; _selectedOrders = newSelection; @@ -1238,10 +1256,6 @@ class _HomepageState extends State Future _fetchActivePickups(int userId) async { try { - final now = DateTime.now(); - final today = - '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; - List itemsV2 = []; try { itemsV2 = await _pickupProvider.getCurrentPickups(); @@ -1249,12 +1263,22 @@ class _HomepageState extends State debugPrint('[HOMEPAGE] Error fetching active bookings: $e'); } + // A stop the rider has already closed is not active, whatever this + // endpoint still says. It lags the confirm by a poll or two, and since + // these get merged into the trip (see [_tripStops]) a stale row would + // reappear on Home wearing its LIVE mark seconds after he finished it. + final closedIds = { + ...await getCompletedOrderIds(), + ...await getSkippedOrderIds(), + }; + final activeOrders = itemsV2.whereType>().where(( - Booking, + booking, ) { - final status = (Booking['orderstatus']?.toString().toLowerCase() ?? '') + final status = (booking['orderstatus']?.toString().toLowerCase() ?? '') .trim(); - return status == 'active'; + if (status != 'active') return false; + return !closedIds.contains((booking['orderid'] ?? '').toString()); }).toList(); if (mounted) { @@ -1267,17 +1291,19 @@ class _HomepageState extends State } } - Future _navigateToActivePickup(Map pickup) async { + /// Opens the map screen for the stop the rider is on, from its row on Home. + /// + /// This used to be `_navigateToActivePickup`, which — despite the name — + /// navigated nowhere: it waited half a second and refetched the queue. The + /// banner that called it was therefore a red strip across the bottom of the + /// screen whose only visible effect on tap was a spinner. Bookings has had + /// the real entry point all along; Home just never used it. + Future _continueStop(Map stop) async { if (!mounted) return; - debugPrint('[HOMEPAGE] Navigate to active pickup: ${pickup['orderid']}'); - if (mounted) { - await Future.delayed(const Duration(milliseconds: 500)); - final prefs = await SharedPreferences.getInstance(); - final userId = prefs.getInt('userid'); - if (userId != null) { - await _fetchQueues(); - } - } + debugPrint('[HOMEPAGE] Continue active stop: ${stop['orderid']}'); + await pickups.MyPickups.navigateToPickupMap(context, stop); + if (!mounted) return; + await _fetchQueues(); } // ==================== PICKUP LOG METHODS ==================== @@ -1386,6 +1412,14 @@ class _HomepageState extends State } if (base == null) return; + // A telemetry log about a booking the backend has never issued is a row + // nobody can join to anything. The meal line's stops exist only on this + // phone until the endpoints land — see [MealRunMock]. + if (MealRunMock.active) { + debugPrint('[MEAL_MOCK] pickup log not posted for $orderId'); + return; + } + final coords = await _getValidCoordinates(); final now = DateTime.now(); final logdate = @@ -1398,7 +1432,6 @@ class _HomepageState extends State 'longitude': (coords?.$2 ?? '0'), }; - await _pickupLogProvider.createPickupLog(payload); } catch (e) { debugPrint('[COLLECTIONLOG][POST] error: $e'); @@ -1449,6 +1482,28 @@ class _HomepageState extends State /// Today's stops, grouped into the trips the hub manager actually assigned. /// + // ── Why [_allTripStops] is NOT filtered through [WorkBoundary] ── + // + // It was, briefly, and it broke two things at once — worth writing down so + // nobody re-applies it. + // + // `WorkBoundary` answers "which screen **works** this order?". Home answers a + // different question: "what is my day?". Those are not the same list. + // + // • `pickupQueue` drops `WorkDomain.closed`, and this list is documented to + // keep finished stops — the trip's progress ring needs them as its + // denominator. Filtering them turned "3 of 8 done" into "3 of 3 done". + // • On logistics the boundary is *acceptance*, so every accepted booking is + // `WorkDomain.delivery`. Home still has to show them: they are the rider's + // run, and the kitchen group expands to list exactly those orders. The + // filter emptied the group and the dropdown stopped opening. + // + // The guarantee the boundary exists for is about **actions**, not about + // display: Deliveries lists only what `deliveryQueue` admits, and Home's own + // rungs are gated by `MilkRun.worksOnOwnScreen`. An order can be *visible* on + // Home as part of the day while being *worked* on Deliveries — what it can + // never be is actionable in two places. + /// Built from [_allTripStops] — every stop in any state, finished ones /// included. The finished stops are hidden from the *list* rather than /// dropped from the trip (see `TripCard._sequence`), because the trip's @@ -1456,11 +1511,93 @@ class _HomepageState extends State /// becomes "0 of 4", and the ring empties itself every time the rider /// finishes something. List get _trips => Trip.groupIntoTrips( - _allTripStops, + _tripStops, hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, ); + /// How much work is waiting on the **other** tab. + /// + /// The number behind the floating pill, whose tap goes straight to + /// Deliveries — so it has to be that tab's own count, or the rider is sent to + /// a screen holding a different number of rows than the badge he pressed. + /// + /// That is exactly what it used to be: `accepted + collected` over Home's own + /// view, which on a kitchen line counted a stop from the moment he accepted + /// it — an hour before anything was in his hands and before Deliveries would + /// list it at all. The pill said 6, the tab showed 0, and neither was wrong + /// about its own question. + /// + /// Asked of [WorkBoundary] now, from the same day and the same sets the other + /// screen fetches with, so the two cannot disagree. + int get _acceptedWaitingCount => WorkBoundary.deliveryQueue( + _tripStops, + collectedIds: _collectedIds, + acceptedIds: _acceptedIds, + ).length; + + /// ── Today's work, as one run ── + /// + /// Every stop the rider holds today, slot boundaries collapsed. This is what + /// the *counters* are derived from — the accepted pill has to mean "you are + /// holding this much", which is a fact about the day and not about whichever + /// tab happens to be on screen. + /// + /// The screen itself is per-trip: the hub assigns work in slots, dispatch + /// rings the rider about "the second trip", and [_trips] is that grouping. See + /// [_selectedTrip] and the pinned [TripTabsHeader] in `build`. + Trip get _todayTrip => Trip.fromStops( + id: 'today', + stops: _tripStops, + hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, + hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, + ); + + /// The trip the tab bar currently points at, or null when that slot has not + /// been assigned yet. + /// + /// Null is a real state, not an error: the tabs deliberately show Trip 2 and + /// Trip 3 before the hub has filled them, so the rider can see that the day + /// has room left in it. That case renders [_buildTripEmptyState], not the + /// whole-day empty state — "nothing in this slot yet" and "you have no work" + /// are different sentences and only one of them is true while Trip 1 runs. + Trip? get _selectedTrip { + final trips = _trips; + // Slot, not position — see [TripSlots.tripAt]. Slot 1 is the afternoon + // whether or not there is morning work, so an empty slot is "nothing + // assigned for that part of the day", not "past the end of the list". + if (_selectedTripIndex < 0) return null; + return trips.tripAt(_selectedTripIndex); + } + + /// [_allTripStops] plus whatever the rider is actually on. + /// + /// The two lists come from different endpoints — the queue, and the + /// current-pickups call behind [_fetchActivePickups] — and the queue does not + /// always still carry a stop once it has gone active. That was survivable + /// while the live stop was announced by its own floating banner; now that the + /// row *is* the announcement, a stop missing from the trip would mean a rider + /// with a job running and nothing on Home saying so. + /// + /// Merged by order id, live copy last so it wins on status. + List> get _tripStops { + if (_activePickups.isEmpty) return _allTripStops; + final live = >{ + for (final s in _activePickups) + if ((s['orderid'] ?? '').toString().isNotEmpty) + (s['orderid'] ?? '').toString(): s, + }; + if (live.isEmpty) return _allTripStops; + + final merged = >[]; + for (final s in _allTripStops) { + final id = (s['orderid'] ?? '').toString(); + merged.add(live.remove(id) ?? s); + } + merged.addAll(live.values); + return merged; + } + /// Ticked stops that are still *this* trip's and still undecided. /// /// Deliberately not just `_selectedStopIds`. That set can outlive the thing @@ -1476,16 +1613,23 @@ class _HomepageState extends State /// /// This mirrors exactly what the trip footer computes for its select-all row, /// so the bar and the row can never disagree about the number. - Iterable _selectedPendingIds() { - final trips = _trips; - if (trips.isEmpty) return const []; - final i = _selectedTripIndex.clamp(0, trips.length - 1); - return trips[i] - .pendingOrderIds(acceptedIds: _acceptedIds, rejectedIds: _rejectedIds) - .where(_selectedStopIds.contains); - } + Iterable _selectedPendingIds() => + (_selectedTrip?.pendingOrderIds( + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + ) ?? + const []) + .where(_selectedStopIds.contains); - int get _selectionCount => _selectedPendingIds().length; + /// How many ticks the bar is acting on. + /// + /// Pending-only for a parcel route, where ticking exists solely to choose + /// what to accept. A service rider keeps his selection through *arrived* and + /// *picked up*, so counting only undecided stops would hide the bar the + /// moment he accepted the very set he is about to walk into a kitchen with. + int get _selectionCount => ServiceProfile.active.handsOffAtCollection + ? _selectedStopIds.where((id) => _stopById(id) != null).length + : _selectedPendingIds().length; /// Accepts exactly the stops the rider ticked. /// @@ -1513,12 +1657,541 @@ class _HomepageState extends State } } + /// Sends the crate photo for a kitchen load. Never throws at the caller. + /// + /// A failed upload costs the hub a picture; a failed upload that surfaced as + /// an error at a counter would cost the rider his place in the queue. So it + /// is logged and dropped — the load itself is already recorded, and that is + /// the part the run depends on. + Future _uploadLoadProof(String path, String kitchenName) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? 0; + if (userId <= 0) return; + final url = await Get.find().uploadProofImage( + File(path), + 'picked', + userId, + 0, + ); + debugPrint('[LOAD] $kitchenName crate photo → ${url ?? 'not stored'}'); + } catch (e) { + debugPrint('[LOAD] crate photo upload failed: $e'); + } + } + + /// Moves one stop up the ladder from its row on Home: `ARRIVED`, then + /// `PICKED`. + /// + /// ── Where the two rungs actually happen ── + /// + /// On a parcel route both are at the customer's door and belong to Bookings. + /// On a service run they are at the **kitchen**: the rider arrives, is handed + /// the food, and only then does the order turn into a delivery. So the stop + /// stays on Home for both, and this is what walks it through them. + /// + /// `PICKED` is the hand-off. It is written to the server — the parcel really + /// has been collected, which is exactly what the status means — and recorded + /// locally as collected, which is what moves the card to Bookings. + Future _advanceStop( + Map stop, + String nextStatus, + ) async { + final orderId = (stop['orderid'] ?? '').toString(); + if (orderId.isEmpty) return; + + try { + final dc = Get.find(); + final pickupId = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0; + final orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0; + final pickupLocationId = + int.tryParse('${stop['pickuplocationid'] ?? 0}') ?? 0; + final riderLoc = await _getValidCoordinates(); + final pickupLat = _parseDouble( + stop['pickuplat'] ?? stop['PickupLat'] ?? 0, + ).toString(); + final pickupLng = _parseDouble( + stop['pickuplon'] ?? stop['PickupLon'] ?? 0, + ).toString(); + + bool ok = false; + if (nextStatus == 'ARRIVED') { + ok = await dc.updateArrivedStatus( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + ridersLat: riderLoc?.$1 ?? '0', + ridersLng: riderLoc?.$2 ?? '0', + pickupLat: pickupLat, + pickupLng: pickupLng, + ); + } else { + ok = await dc.updatePickedStatus( + pickupId: pickupId, + orderId: orderId, + orderHeaderId: orderHeaderId, + pickupLocationId: pickupLocationId, + ridersLat: riderLoc?.$1 ?? '0', + ridersLng: riderLoc?.$2 ?? '0', + pickupLat: pickupLat, + pickupLng: pickupLng, + notes: '', + ); + } + + // ── A failed write moves nothing ── + // + // Every other status write on this screen is optimistic, and for arrival + // that is right: the rider is standing at a counter, the row is his own + // report of where he is, and snapping it back because a poll is late + // reads as the button having done nothing. + // + // Pickup-complete is not that kind of write. It is the boundary between + // the two halves of the app — see [WorkBoundary] — and crossing it on an + // unconfirmed call is how an order ends up on Deliveries while the + // backend still has it assigned for collection, which no refresh can + // repair because the local record outranks the queue by design. So the + // hand-over waits for the server, and a failure leaves the stop exactly + // where it is, on Home, still to be picked up. + if (!ok) { + debugPrint('[ADVANCE] $orderId -> $nextStatus reported failure'); + if (nextStatus == 'PICKED') return; + } + + // ── The rung the SERVER produced, not the one the button is named after ── + // + // `picked` was hardcoded here. In compatibility mode `pickup-complete` + // releases the consignment in the same call, so the server's answer is + // `Out_for_Delivery` — and stamping `picked` over it put the rider's + // card one rung behind his own consignment and behind the console, which + // reads the same state and calls it Active. Two screens, two truths, one + // stop. + // + // `lastPivotConsignmentStatus` is whatever the pivot actually reported. + // Absent (an older backend, a response that named nothing) it falls back + // to `picked`, which is the honest reading of "collected, nothing said + // about release". + if (nextStatus == 'ARRIVED') { + stop['orderstatus'] = 'arrived'; + } else { + final produced = ConsignmentState.values + .where((s) => s.name == dc.lastPivotConsignmentStatus) + .firstOrNull; + final legacy = produced == null + ? '' + : ApiConfig.legacyStatusFromConsignment(produced.name); + stop['orderstatus'] = legacy.isEmpty ? 'picked' : legacy; + if (produced != null) stop['consignmentstatus'] = produced.name; + } + + // Written at the one moment it is true. Nothing else records these two: + // the backend keeps a booking's *current* status and returns no event + // feed, so without this the rider's own history of his morning is three + // rungs long. See [OrderEvent]. + await stampOrderEvent( + orderId, + nextStatus == 'ARRIVED' + ? OrderEvent.arrivedAtPickup + : OrderEvent.pickedUp, + ); + + if (nextStatus == 'PICKED') { + // Confirmed by the backend, so it is safe to record: this is the write + // that hands the order over to Deliveries. + await addCollectedOrderIds([orderId]); + await addAcceptedBookings([stop]); + // One copy of the day, and it is now stale — the other screen must not + // be able to render this order from a cached response that predates the + // hand-over. See [WorkRepository]. + unawaited(WorkRepository.instance.invalidate()); + if (!mounted) return; + setState(() => _collectedIds = {..._collectedIds, orderId}); + } + + // ── When the hub did not hear it, say so ── + // + // Once, plainly, and without blocking anything. `reached` currently + // answers 200 and writes nothing (see + // [PickupsController.updateArrivedStatus]), so the rider's ARRIVED is + // his own record and not a shared one — and the first person to find + // that out should not be an operator wondering why a rider has been at a + // kitchen for forty minutes. + final notice = dc.lastArrivalNotice; + if (nextStatus == 'ARRIVED' && notice != null && mounted) { + AppFeedback.infoGlobal(notice); + } + } catch (e) { + debugPrint('[ADVANCE] $orderId -> $nextStatus failed: $e'); + } + } + + /// Walks one stop up its next rung, behind a slide-to-confirm sheet. + /// + /// ── Why the whole kitchen goes together ── + /// + /// He arrived at a counter, not at an order. Confirming arrival for one bag + /// and leaving its two siblings on `accepted` would post three different + /// states for one event and leave him sliding the same sheet three times at + /// the same window. So the sheet acts on every stop from that source that is + /// on the same rung — which is exactly the set the batch bar would have + /// acted on, reached without having to tick anything. + /// + /// Collection is different and stays per bag on the group page; this is the + /// arrival, which is genuinely one event. + Future _advanceStopWithSheet(Map stop) async { + final state = stopStateOf( + stop, + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + collectedIds: _collectedIds, + ); + final rung = switch (state) { + StopState.accepted => StopStatus.accepted, + StopState.active => StopStatus.arrived, + _ => null, + }; + if (rung == null) return; + + // Siblings at the same counter, on the same rung. + final batch = [ + for (final s in MilkRun.sameSourceAs(stop, _allTripStops)) + if (stopStateOf( + s, + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + collectedIds: _collectedIds, + ) == + state) + s, + ]; + final ids = batch.map(MilkRun.idOf).where((id) => id.isNotEmpty).toList(); + if (ids.isEmpty) return; + + final place = rung == StopStatus.accepted + ? MilkRun.navigationLabel(stop) + : MilkRun.sourceNameOf(stop); + + final done = await StopActionSheet.show( + context, + stage: rung, + placeName: place, + count: ids.length, + lines: BagManifest.forGroup(batch), + onConfirm: (outcome) async { + setState(() => _busyStopIds.addAll(ids)); + try { + final missing = outcome.missingOrderIds.toSet(); + final loaded = [ + for (final s in batch) + if (!missing.contains(MilkRun.idOf(s))) s, + ]; + for (final s in loaded) { + await _advanceStop( + s, + rung == StopStatus.accepted ? 'ARRIVED' : 'PICKED', + ); + } + if (rung == StopStatus.arrived) { + await saveBagLabels({ + for (final line in BagManifest.forGroup(loaded)) + line.orderId: line.bag, + }); + } + if (missing.isNotEmpty) { + await addNotLoadedOrderIds(missing.toList()); + if (mounted) { + setState(() { + for (final id in missing) { + _hiddenOrderIds.add(id); + _ordersMap.remove(id); + _orderIds.remove(id); + _selectedOrders.remove(id); + } + }); + } + } + if (outcome.photoPath != null) { + unawaited(_uploadLoadProof(outcome.photoPath!, place)); + } + return null; + } catch (e) { + debugPrint('[RUNG] $ids failed: $e'); + return 'Could not update — check your connection and try again'; + } finally { + if (mounted) setState(() => _busyStopIds.removeAll(ids)); + } + }, + ); + + if (!done || !mounted) return; + AppFeedback.successGlobal( + rung == StopStatus.accepted + ? 'Arrived · ${ids.length} ready to collect' + : '${ids.length} picked up — deliver them from ' + '${ServiceProfile.active.workTabLabel}', + ); + await _fetchQueues(); + } + + /// Opens turn-by-turn navigation for one stop. + /// + /// The destination is [MilkRun.navigationTarget]'s, not this screen's, so the + /// row icon, the detail sheet and the stop map cannot disagree about where a + /// given order is going. Silent when the stop carries no coordinates for its + /// current leg — the row hides the control in that case, so reaching here + /// without one means the data changed under a rebuild. + Future _navigateToStop(Map stop) async { + final target = MilkRun.navigationTarget(stop, collectedIds: _collectedIds); + if (target == null) { + if (mounted) { + AppFeedback.info(context, 'No location on this stop yet'); + } + return; + } + await _openExternalNavigation(target.lat, target.lng); + } + + /// Hands the coordinates to the phone's own maps app. + /// + /// Native intent first, web as the fallback — the same order the stop map + /// screen uses, so a rider gets the same app whichever control he presses. + Future _openExternalNavigation(double lat, double lng) async { + try { + final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d'); + if (await launchUrl(native, mode: LaunchMode.externalApplication)) return; + } catch (_) {} + try { + await launchUrl( + Uri.parse( + 'https://www.google.com/maps/dir/?api=1' + '&destination=$lat,$lng&travelmode=driving', + ), + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[NAV] could not open maps: $e'); + } + } + + /// The rung the ticked stops are all on, and therefore what the bar offers. + /// + /// Mixed selections fall back to accepting: that is the only rung that is + /// safe to run against a set in different states, because it is a no-op on + /// anything already taken on. + SelectionAction _selectionAction() { + if (!ServiceProfile.active.handsOffAtCollection) { + return SelectionAction.accept; + } + final states = { + for (final id in _selectedStopIds) + if (_stopById(id) != null) + stopStateOf( + _stopById(id)!, + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + collectedIds: _collectedIds, + ), + }; + if (states.length != 1) return SelectionAction.accept; + return switch (states.first) { + StopState.accepted => SelectionAction.arrive, + StopState.active => SelectionAction.pickUp, + _ => SelectionAction.accept, + }; + } + + Map? _stopById(String orderId) { + for (final s in _allTripStops) { + if ((s['orderid'] ?? '').toString() == orderId) return s; + } + return null; + } + + /// Walks every ticked stop up one rung, in one gesture. + /// + /// This is the shape of the job at a kitchen: five parcels come off one + /// counter in one handover, so the rider ticks the five and confirms once — + /// not five times, twice. `PICKED` is also the hand-off, so the batch that + /// finishes it is the batch that lands on the deliveries tab. + /// + /// ── Two steps, on purpose ── + /// + /// The bar's button *opens* the confirmation; [StopActionSheet] carries the + /// slide that commits it. Pressing "Mark as Picked" with a crate in one hand + /// must not be the thing that writes to the hub — the press is easy to make + /// by accident, the slide is not, and the sheet is where there is finally + /// room to say which counter and how many orders he is speaking for. + Future _advanceSelection() async { + final action = _selectionAction(); + final ids = _selectedStopIds.toList(); + if (ids.isEmpty || action == SelectionAction.accept) return; + + final stops = [ + for (final id in ids) + if (_stopById(id) != null) _stopById(id)!, + ]; + if (stops.isEmpty) return; + + final arriving = action == SelectionAction.arrive; + // Where he is. Every stop in a valid selection is on the same rung; when + // they are also from one counter — the normal case, and the one the rider + // is standing in — the sheet can name it. A mixed pick says how many + // places instead of naming the wrong one. + final places = { + for (final s in stops) + arriving ? MilkRun.navigationLabel(s) : MilkRun.sourceNameOf(s), + }..removeWhere((p) => p.trim().isEmpty); + final place = places.length == 1 ? places.first : ''; + + final done = await StopActionSheet.show( + context, + stage: arriving ? StopStatus.accepted : StopStatus.arrived, + placeName: place, + count: ids.length, + // Named, with the bag each one travels in — this is the list he checks + // against the shelf in front of him. One order, one bag; see + // [BagManifest]. + lines: BagManifest.forGroup(stops), + onConfirm: (outcome) async { + setState(() => _busyStopIds.addAll(ids)); + try { + // ── A bag the counter could not supply ── + // + // It does not move up the ladder: reporting it as picked would put a + // lunch on the round that is not in the box. It leaves the route with + // its own record instead — not a skip and not a cancellation, because + // nobody was visited and the customer did nothing. This is the + // missing-item path the separate collect sheet used to own; it lives + // here now because this is the only place a pickup is confirmed. + final missing = outcome.missingOrderIds.toSet(); + final loaded = [ + for (final stop in stops) + if (!missing.contains(MilkRun.idOf(stop))) stop, + ]; + + for (final stop in loaded) { + await _advanceStop(stop, action.status); + } + // ── The pairing is fixed here, and never recomputed ── + // + // Joe is Bag 1 of the five that left this counter. Deriving that + // later from whatever list is on screen breaks it the moment one is + // delivered, so it is written down at the one moment it is true and + // read back unchanged for the rest of the round. See [BagManifest]. + if (action == SelectionAction.pickUp) { + await saveBagLabels({ + for (final line in BagManifest.forGroup(loaded)) + line.orderId: line.bag, + }); + } + if (missing.isNotEmpty) { + await addNotLoadedOrderIds(missing.toList()); + } + // One shot of the whole load, uploaded against the load rather than + // any one order. Fire-and-forget: a rider at a hatch must not wait on + // kitchen wifi, and the pickup is recorded either way. + if (outcome.photoPath != null) { + unawaited(_uploadLoadProof(outcome.photoPath!, place)); + } + // ── Collecting is not setting off ── + // + // The release used to ride along with the pickup here, so the moment + // the rider slid to confirm five bags at a counter the consignments + // went `Out_for_Delivery` and the hub's board showed five *active* + // deliveries — for food still on the shelf in front of him. Picked + // and out-for-delivery are two different claims about where the load + // is, and only he can make the second one. + // + // It moved to where the load is: the START DELIVERY bar at the foot + // of the deliveries tab. See [MyPickups.startRound]. + if (mounted && missing.isNotEmpty) { + setState(() { + for (final id in missing) { + _hiddenOrderIds.add(id); + _ordersMap.remove(id); + _orderIds.remove(id); + _selectedOrders.remove(id); + } + }); + } + return null; + } catch (e) { + debugPrint('[RUNG] $ids failed: $e'); + return 'Could not update — check your connection and try again'; + } finally { + if (mounted) setState(() => _busyStopIds.removeAll(ids)); + } + }, + ); + + if (!done || !mounted) return; + // The selection has served its purpose — and on a pick-up it now points at + // orders that have left this screen for the deliveries tab. + setState(() => _selectedStopIds = {}); + AppFeedback.successGlobal( + action == SelectionAction.pickUp + ? '${ids.length} picked up — deliver them from ' + '${ServiceProfile.active.workTabLabel}' + : 'Arrived · ${ids.length} ready to collect', + ); + await _fetchQueues(); + } + void _toggleStopSelection(String orderId) { - setState(() { - final next = {..._selectedStopIds}; - next.contains(orderId) ? next.remove(orderId) : next.add(orderId); - _selectedStopIds = next; - }); + // Unticking is never blocked — a rider must always be able to back out of + // a selection he is halfway through. + if (_selectedStopIds.contains(orderId)) { + setState(() => _selectedStopIds = {..._selectedStopIds}..remove(orderId)); + return; + } + + // ── One kitchen at a time, once he is past accepting ── + // + // Acceptance is per order and crosses kitchens freely: he takes on all six + // of the morning's orders in one press, whichever counter they come from. + // + // **Pickup does not.** Arriving and collecting happen at one counter, and a + // selection spanning two kitchens would post "arrived" for a kitchen the + // rider is nowhere near and mark bags collected that are still in another + // shop's warmer. So the moment the ticked set is accepted work, it is bound + // to a single source. + // + // Refused with the reason rather than silently swapping the selection: + // dropping his existing ticks to honour the newest tap loses work he did + // deliberately, and he would not see which ones went. + final incoming = _stopById(orderId); + if (incoming != null && _isPickupSelection && _selectedStopIds.isNotEmpty) { + final anchor = _stopById(_selectedStopIds.first); + if (anchor != null && !MilkRun.sameSource(anchor, incoming)) { + final anchorName = MilkRun.sourceNameOf(anchor); + AppFeedback.info( + context, + anchorName.isEmpty + ? 'Collect one kitchen at a time — finish this pickup first' + : 'Finish the $anchorName pickup first, then this one', + ); + return; + } + } + + setState(() => _selectedStopIds = {..._selectedStopIds}..add(orderId)); + } + + /// True when the ticked set is accepted work — i.e. the next slide is a + /// pickup rung rather than an accept, and the kitchen bound applies. + bool get _isPickupSelection { + if (!ServiceProfile.active.handsOffAtCollection) return false; + for (final id in _selectedStopIds) { + final stop = _stopById(id); + if (stop == null) continue; + final state = stopStateOf( + stop, + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + collectedIds: _collectedIds, + ); + if (state == StopState.accepted || state == StopState.active) return true; + } + return false; } /// Declines exactly the stops the rider ticked, after asking why once. @@ -1578,76 +2251,43 @@ class _HomepageState extends State 'Address looks wrong', ]; - return showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - backgroundColor: ColorConstants.pureSurface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => SafeArea( - child: Padding( - padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 16.h), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40.w, - height: 4.h, - decoration: BoxDecoration( - color: ColorConstants.borderStrong, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - ), - ), - ), - SizedBox(height: 16.h), - Text( - count == 1 - ? 'Why are you rejecting this stop?' - : 'Why are you rejecting these $count stops?', - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 4.h), - Text( - count == 1 - ? 'The hub needs a reason to re-route it to someone else.' - : 'The hub needs a reason to re-route them to someone else.', - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 14.h), - for (final r in reasons) ...[ - MilerButton( - label: r, - variant: MilerButtonVariant.outlined, - color: ColorConstants.slateText, - height: ButtonSizes.secondary, - onPressed: () => Navigator.pop(ctx, r), - ), - SizedBox(height: 8.h), - ], - SizedBox(height: 4.h), + // Through the kit; content unchanged. (This one also applied a bare + // `SafeArea` INSIDE an opaque sheet — the scaffold's single inset rule + // replaces it.) + return showMilerSheet( + context, + builder: (ctx) => MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MilerSheetHeader( + title: count == 1 + ? 'Why are you rejecting this stop?' + : 'Why are you rejecting these $count stops?', + subtitle: count == 1 + ? 'The hub needs a reason to re-route it to someone else.' + : 'The hub needs a reason to re-route them to someone else.', + ), + SizedBox(height: 14.h), + for (final r in reasons) ...[ MilerButton( - label: count == 1 ? 'Keep this stop' : 'Keep them', - variant: MilerButtonVariant.tonal, - color: ColorConstants.acceptGreen, - onPressed: () => Navigator.pop(ctx, null), + label: r, + variant: MilerButtonVariant.outlined, + color: ColorConstants.slateText, + height: ButtonSizes.secondary, + onPressed: () => Navigator.pop(ctx, r), ), + SizedBox(height: 8.h), ], - ), + SizedBox(height: 4.h), + MilerButton( + label: count == 1 ? 'Keep this stop' : 'Keep them', + variant: MilerButtonVariant.tonal, + color: ColorConstants.acceptGreen, + onPressed: () => Navigator.pop(ctx, null), + ), + ], ), ), ); @@ -1756,6 +2396,16 @@ class _HomepageState extends State await addRejectedOrderIds([orderId]); if (success) dc.triggerRefresh(); + // ── A declined stop is filed, not deleted ── + // + // It used to stay on Home, struck through, with an UNDO beside it, and + // it was recorded nowhere else — so the only record of a decision the + // hub will ask about lived on the one screen the rider clears as he + // works. Now it leaves Home immediately (see `TripCard._showsOnHome`) + // and lands on Activity with the rest of the day, stamped `rejected` so + // the row can say **Declined** rather than pretending it was completed. + await addCompletedBookings([stop], terminalStatus: 'rejected'); + if (!mounted) return; await removeAcceptedBookings([orderId]); if (!mounted) return; @@ -1783,61 +2433,95 @@ class _HomepageState extends State /// The action is a plain text label rather than anything filled: Material is /// explicit that a snackbar action must not out-shout the message it sits /// beside, and this one is a safety net, not the main path. + /// ── A confirmation, not a thing to dismiss ── + /// + /// This was a Material `SnackBar` at 4 seconds — 8 on an accept, to leave + /// room for an UNDO. On the rider's phone that reads as a panel that has + /// *arrived and stayed*: it covers the foot of the route, it sits over the + /// floating nav bar's neighbourhood, and the only obvious way to be rid of it + /// is to swipe it down. Confirming six accepted stops is not worth eight + /// seconds of the screen the rider is trying to keep working on. + /// + /// `Flushbar` (another_flushbar, pub.dev) at **2 seconds**: it announces, it + /// leaves, and nothing has to be dismissed. It also animates in and out on + /// its own clock rather than the Scaffold's, so it is unaffected by the tab + /// switch an accept triggers. + /// + /// [duration] is kept as a parameter but defaults to the 2s the rider asked + /// for; anything that genuinely needs longer has to say so at the call site. void _showStopSnack( String message, Color color, { String? actionLabel, VoidCallback? onAction, - Duration duration = const Duration(seconds: 4), + Duration duration = const Duration(seconds: 2), }) { if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); - // Only one at a time. Two accepts in quick succession would otherwise - // queue, and the rider would be looking at an UNDO belonging to the stop - // before the one he is thinking about. - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text( - message, - style: const TextStyle( - fontFamily: FontConstants.fontFamily, - color: Colors.white, - ), + + // One at a time. Two accepts in quick succession would otherwise stack. + _flushbar?.dismiss(); + + final bar = Flushbar( + messageText: Text( + message, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: Colors.white, ), - backgroundColor: color, - behavior: SnackBarBehavior.floating, - // Clear of the floating nav bar. - // - // A floating snackbar sits 16 above the bottom of *this* Scaffold, and - // this Scaffold runs the full height of the screen — the tab bar is a - // sibling layer painted over it by [BottomPage]. So the default margin - // put the snackbar underneath the bar, and the action sits at the - // snackbar's right edge, which is exactly where the frosted panel is - // opaque. An UNDO the rider cannot see or press is worse than no undo - // at all: he taps the bar's tab instead and walks away believing the - // acceptance is reversed. - margin: EdgeInsets.fromLTRB( - 16.w, - 0, - 16.w, - BottomPage.bottomInset(context) + 8.h, - ), - duration: duration, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - action: (actionLabel == null || onAction == null) - ? null - : SnackBarAction( - label: actionLabel, - textColor: Colors.white, - onPressed: onAction, - ), ), + icon: Icon( + LucideIcons.circleCheck, + size: 20.sp, + color: Colors.white.withValues(alpha: 0.95), + ), + backgroundColor: color, + duration: duration, + flushbarPosition: FlushbarPosition.BOTTOM, + flushbarStyle: FlushbarStyle.FLOATING, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + // Clear of the floating nav bar — the bar is a sibling layer painted over + // this Scaffold, so a toast measured from the Scaffold's own bottom lands + // underneath it. + margin: EdgeInsets.fromLTRB( + 16.w, + 0, + 16.w, + BottomPage.bottomInset(context) + 8.h, + ), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), + animationDuration: const Duration(milliseconds: 260), + forwardAnimationCurve: Curves.easeOutCubic, + reverseAnimationCurve: Curves.easeInCubic, + // Two seconds is not long enough to reach for an action, so a toast that + // carries one is a toast making a promise it cannot keep. Anything + // reversible is reversed from the tab the work landed on. + mainButton: (actionLabel == null || onAction == null) + ? null + : TextButton( + onPressed: () { + _flushbar?.dismiss(); + onAction(); + }, + child: Text( + actionLabel, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), ); + _flushbar = bar; + bar.show(context); } + /// The toast currently on screen, so a second one replaces it rather than + /// stacking behind it. + Flushbar? _flushbar; + /// The stops the last accept actually took, held for the UNDO on its /// snackbar. Cleared implicitly — the next accept overwrites it, and undoing /// checks each stop is still accepted before touching it. @@ -1999,13 +2683,30 @@ class _HomepageState extends State ridersLat: bulkRidersLat, ridersLng: bulkRidersLng, ); - // Optimistic, mirroring the single-accept path: persist EVERY - // selected booking regardless of the per-call network result, so - // all of them reliably land on the Bookings tab. Previously this - // was gated on `success`, so if some accept calls failed only a - // subset reached Bookings even though the user accepted all. - _hiddenOrderIds.add(orderId); - acceptedBookings.add(Booking); + // ── An accept the backend refused is not an accept ── + // + // This persisted EVERY selected booking regardless of the result, + // on the reasoning that a flaky network should not cost the rider + // his selection. What it actually produced was a booking the app + // believed was accepted and the hub had never heard of — and every + // rung after it inherits that lie: the row advances to arrived and + // picked, `pickup-complete` is posted against a booking still + // sitting in `Miler_Assigned`, no consignment is ever created, and + // the rider finds out at a customer's door when Delivered is + // refused because there is nothing to deliver. + // + // Accept is the one rung with a cheap retry: the stop is still on + // Home, still selectable, and pressing again costs a tap. So it + // records what the backend confirmed and says what it did not — + // the same rule `_advanceStop` already applies to PICKED. + if (success) { + _hiddenOrderIds.add(orderId); + acceptedBookings.add(Booking); + } else { + debugPrint( + '[BULK] accept REFUSED for $orderId — leaving it on Home', + ); + } } else if (targetStatus == 'ARRIVED') { debugPrint('[BULK] Arriving Booking $orderId'); success = await dc.updateArrivedStatus( @@ -2020,6 +2721,7 @@ class _HomepageState extends State debugPrint('[BULK] Picking Booking $orderId'); success = await dc.updatePickedStatus( pickupId: pickupId, + orderId: orderId, orderHeaderId: orderHeaderId, pickupLocationId: pickupLocationId, ridersLat: bulkRidersLat, @@ -2055,6 +2757,10 @@ class _HomepageState extends State // keeps them filtered out. if (acceptedBookings.isNotEmpty) { await addAcceptedBookings(acceptedBookings); + await stampOrderEvents( + acceptedBookings.map((b) => (b['orderid'] ?? '').toString()), + OrderEvent.accepted, + ); } _lastAcceptedBatch = List>.from(acceptedBookings); @@ -2068,11 +2774,28 @@ class _HomepageState extends State _selectedOrders.clear(); isAllSelected = false; }); - // Accepts persist optimistically, so treat the batch as succeeded when - // anything was accepted even if some network calls failed. - bulkSucceeded = successCount > 0 || acceptedBookings.isNotEmpty; + bulkSucceeded = acceptedBookings.isNotEmpty || successCount > 0; await _fetchQueues(); + // ── A partial accept has to say so ── + // + // The accept path used to skip this message entirely, on the grounds that + // it navigates away. It now records only what the backend confirmed, so + // silence would leave the rider looking at stops that did not move with + // nothing to explain why. + if (mounted && + targetStatus == 'ACCEPTED' && + acceptedBookings.length != selectedOrderIds.length) { + final missed = selectedOrderIds.length - acceptedBookings.length; + AppFeedback.error( + context, + acceptedBookings.isEmpty + ? 'Could not accept — the hub did not confirm it. Try again.' + : '$missed of ${selectedOrderIds.length} could not be accepted — ' + 'they are still on Home.', + ); + } + // For accepts we navigate to the Bookings tab below, so skip the inline // snackbar in that case (it would be tied to this soon-to-be-replaced page). if (mounted && targetStatus != 'ACCEPTED') { @@ -2132,7 +2855,25 @@ class _HomepageState extends State } catch (e) { debugPrint('[BULK] Could not poke Bookings to refresh: $e'); } - BottomPage.goToTab(1); + + // ── Only the line that hands off at accept gets moved ── + // + // On **logistics** accepting IS the hand-off: the stop leaves Home for + // the work tab, so following it there is the whole point — see the note + // above. + // + // On a **milk run** it is not. The order stays on Home to be walked up + // its ladder — accepted, arrived, picked — and only reaches the + // deliveries tab once it is in the rider's hands. Jumping him there on + // accept sent him to a tab that was correctly empty and made the accept + // look like it had filed his order somewhere else. He then had to find + // his way back to Home to do the actual collecting. + // + // So he stays where the work is. The snackbar below still confirms what + // he took, and the accepted rows are right there under it. + if (!ServiceProfile.active.handsOffAtCollection) { + BottomPage.goToTab(1); + } // ── The undo window ── // @@ -2141,18 +2882,22 @@ class _HomepageState extends State // but it is also *bigger*, because one press can take six stops. Eight // seconds to take the whole batch back, the same trade Gmail's delete // and Material's snackbar guidance make. + // Two seconds, and no UNDO on it. An eight-second panel held the foot of + // the screen open purely so an action nobody could reach in time had + // somewhere to live; the acceptance itself is reversible from the tab the + // work lands on, which is where the rider is by the time he changes his + // mind. _showStopSnack( n == 1 ? 'Stop accepted' : '$n stops accepted', ColorConstants.acceptGreen, - actionLabel: taken.isEmpty ? null : 'UNDO', - onAction: taken.isEmpty ? null : () => _undoAcceptedBatch(taken), - duration: const Duration(seconds: 8), ); return; } if (bulkSucceeded && targetStatus == 'ACCEPTED' && navigateToBookings) { - AppFeedback.successGlobal('Moved to your Bookings'); + AppFeedback.successGlobal( + 'Moved to your ${ServiceProfile.active.workTabLabel}', + ); Get.offAll(() => const BottomPage(initialIndex: 1)); } } @@ -2189,6 +2934,47 @@ class _HomepageState extends State // the page has to hand this down. brief: _routeBrief, busyStopIds: _busyStopIds, + collectedIds: _collectedIds, + // How far the next pickup is. The same fix the detail sheet and the map + // already use — a live geocoded position, falling back to the last one + // stored — so the distance on the route header and the distance inside the + // stop cannot disagree. + riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, + riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, + // ── The card opens a preview, and the preview hands off ── + // + // `Navigate to pickup` used to go straight out to Google Maps. That is one + // tap too few: handing off means the rider *leaves Miler*, and he was + // making that commitment on the strength of a kitchen's name and a + // straight-line distance. The preview draws the leg from where he is + // standing to the counter and lists what he will collect there; its own + // primary button is the real hand-off. Built here rather than inside + // [TripCard] because that class's three build methods are deliberately + // context-free — Home calls them directly as slivers. + onPreviewGroup: (g) => PickupPreviewSheet.show( + context, + placeName: g.name, + lines: g.manifest, + meters: g.meters, + ride: g.rideTime, + riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, + riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, + onNavigate: + g.stops.isEmpty || MilkRun.navigationTarget(g.stops.first) == null + ? null + : () { + Navigator.of(context).pop(); + _navigateToStop(g.stops.first); + }, + onCall: g.stops.isEmpty || stopPhoneOf(g.stops.first).isEmpty + ? null + : () { + Navigator.of(context).pop(); + _callStop(g.stops.first); + }, + ), + // Only a tenant that collects from a source gets the control; on a parcel + // route there is nothing to collect from and the header never renders. selectedIds: _selectedStopIds, onToggleSelect: _toggleStopSelection, onSelectAll: (ids) => setState(() => _selectedStopIds = ids.toSet()), @@ -2209,6 +2995,17 @@ class _HomepageState extends State riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, ), onCallStop: (stop) => _callStop(stop), + // Setting off, straight from the row. Where it points follows the stop's + // stage: the kitchen while it is still to be collected, the customer once + // it is in his hands. + onNavigateStop: _navigateToStop, + // The single-stop ladder: a slide sheet per card, alongside the floating + // bar's batch version. + onAdvanceStop: _advanceStopWithSheet, + // Back into the stop he is already on. This is what the red "Active + // Pickup" strip at the foot of the page used to be — except that one + // only refetched the queue and never actually opened anything. + onContinueStop: _continueStop, // The whole route on one map. Reads the trip's own ordering into the // step numbers so the pins are labelled the way the card lists them, // rather than however the map screen would number them itself. @@ -2247,23 +3044,31 @@ class _HomepageState extends State : "Rider"; return AnnotatedRegion( + // ── The status bar belongs to the header ── + // + // On a brand ground the clock and the battery have to invert with it, or + // the top 44pt of the phone is dark glyphs on maroon — which is the one + // contrast failure a rider cannot work around by tilting the screen. value: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, ), child: Container( - decoration: BoxDecoration( - // The same frosted brand wash the other tabs' bars wear, flattened - // against white. Home's header is a pinned Container rather than an - // AppBar — nothing scrolls behind it, so there is nothing for a real - // blur to work on — but the colour has to match [milerGlassSurface] - // exactly or switching tabs would change the tint at the top of the - // screen. Composited from the same token, so it cannot drift. - color: Color.alphaBlend( - ColorConstants.glassRed, - ColorConstants.pureSurface, - ), + decoration: const BoxDecoration( + // ── A branded header, not a coloured rectangle ── + // + // It wore the same 7% frosted wash as the other three tabs, flattened + // against white. That is right for a tab you pass through; Home is + // the screen the rider *lives* on, and it is the one place the app + // gets to say whose app it is without spending a logo on it. + // + // Solid brand, and everything on it inverts: the greeting, the duty + // word, the switch, the status-bar glyphs. Nothing is tinted — white + // on `#960019` clears AA at every size used here, and a "mostly + // white" header text would be the one thing on the bar that looks + // like a mistake. + color: ColorConstants.primary, ), child: SafeArea( // Inset handled explicitly below via MediaQuery so the status bar @@ -2281,11 +3086,16 @@ class _HomepageState extends State // // Bottom inset is the bar's own breathing room rather than a gap // before the shift row, so it stays tight at 12. + // 28 → 20 above, 12 → 16 below. On white the extra top inset was + // stopping the greeting reading as clipped against the status bar; + // on a solid ground the bar *is* the status bar's surface, so the + // gap above can close and the type sits in a balanced band instead + // of being pushed to its floor. padding: EdgeInsets.fromLTRB( + 20.w, + MediaQuery.of(context).padding.top + 20.h, 16.w, - MediaQuery.of(context).padding.top + 28.h, - 16.w, - 12.h, + 16.h, ), child: Column( children: [ @@ -2304,13 +3114,29 @@ class _HomepageState extends State // entirely about the person using the app. Row( children: [ - MilerAvatar(name: _userName), - SizedBox(width: 10.w), + MilerAvatar(name: _userName, onBrand: true), + SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // ── An eyebrow the greeting can lead ── + // + // "Hi, Suriya" alone was one line floating in a band + // with a switch. Naming the line of work above it + // gives the header a subject and a predicate — this + // is a *shift*, and here is whose — and it is the one + // place the tenant's operation is stated at all. + Text( + ServiceProfile.active.label.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.eyebrow.copyWith( + color: Colors.white.withValues(alpha: 0.72), + ), + ), + SizedBox(height: 3.h), // Greeting + name is the anchor of the screen, so it // gets the heaviest type in the header. Text( @@ -2318,18 +3144,18 @@ class _HomepageState extends State maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 23.sp, - fontWeight: FontWeight.w800, + fontSize: 22.sp, + fontWeight: FontWeight.w700, height: 1.1, letterSpacing: -0.5, - color: ColorConstants.slateText, + color: ColorConstants.onAccent, fontFamily: FontConstants.fontFamily, ), ), ], ), ), - SizedBox(width: 10.w), + SizedBox(width: 12.w), _buildStatusToggle(), ], ), @@ -2366,17 +3192,28 @@ class _HomepageState extends State final bool locked = _hasActivePickup(); final bool on = isOnline; - // M3 switch geometry. Fixed rather than `.w`-scaled: a switch that changes - // size with the design scale stops reading as the same object between - // devices, and the thumb travel is computed from these. - const double trackW = 52; - const double trackH = 32; - final double handle = on ? 24 : 16; - - final Color trackFill = on ? _successColor : ColorConstants.neutralLight; - final Color trackEdge = on ? _successColor : ColorConstants.borderStrong; - final Color handleFill = on ? Colors.white : ColorConstants.borderStrong; - + // ── Both states on screen, all the time ── + // + // This was a hand-rolled M3 switch: a track, a travelling handle, and the + // word for the CURRENT state beside it. Two problems a rider has on a bike. + // A bare switch is a *guess* — "is this the on position or the off one?" — + // answered only by remembering which side means what; and the word changed + // as he pressed it, so the label he was reading and the control he was + // aiming at both moved. + // + // `AnimatedToggleSwitch.dual` (pub.dev, ~2k likes, actively maintained) is + // the shape this control always wanted: a pill holding the state as a word + // AND a knob whose position says the same thing a second time, sliding + // between them. Nothing is inferred from a position alone, and the package + // brings the rolling indicator, the touch-target floor and the disabled + // state with it rather than this file growing its own. + // + // On the brand ground it stays monochrome. Green-on-maroon is the one + // pairing this palette cannot make: the two are close in luminance, so a + // green "on" read as a smudge. **On** is a solid white knob carrying a + // brand tick — the strongest mark available here, and the live state is + // correctly the filled one. **Off** is a hollow white knob on a translucent + // track: unlit, not absent. Neither depends on hue. return Semantics( toggled: on, enabled: !locked, @@ -2385,82 +3222,58 @@ class _HomepageState extends State : on ? 'On duty. Switch off to stop receiving trips.' : 'Off duty. Switch on to receive trips.', - child: Opacity( - opacity: locked ? 0.55 : 1, - child: Material( - color: Colors.transparent, + excludeSemantics: true, + child: AnimatedToggleSwitch.dual( + current: on, + first: false, + second: true, + // Locked mid-pickup: going off duty would strand a customer. The + // package dims rather than removing it, so the state stays readable. + active: !locked, + inactiveOpacity: 0.55, + height: 34.h, + spacing: 6.w, + borderWidth: 1.5, + indicatorSize: Size.fromWidth(30.w), + animationDuration: const Duration(milliseconds: 260), + animationCurve: Curves.easeOutCubic, + style: ToggleStyle( + backgroundColor: on + ? Colors.white.withValues(alpha: 0.18) + : Colors.white.withValues(alpha: 0.12), + borderColor: Colors.white.withValues(alpha: on ? 0.85 : 0.40), + indicatorColor: Colors.white, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - child: InkWell( - onTap: locked ? null : _confirmOnlineOffline, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 8.h), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // The word carries the state for anyone who cannot rely on - // the fill — sunlight, colour vision, a scratched screen. - Text( - on ? 'On duty' : 'Off duty', - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - color: on ? _successColor : ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(width: 8.w), - AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: trackW, - height: trackH, - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: trackFill, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - border: Border.all(color: trackEdge, width: 2), - ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - // Left for off, right for on. The first version of this - // control had it the other way round. - alignment: on - ? Alignment.centerRight - : Alignment.centerLeft, - child: AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: handle, - height: handle, - alignment: Alignment.center, - decoration: BoxDecoration( - color: handleFill, - shape: BoxShape.circle, - ), - child: locked - ? Icon( - Icons.lock_rounded, - size: 13.sp, - color: _successColor, - ) - : on - ? Icon( - Icons.check_rounded, - size: 15.sp, - color: _successColor, - ) - : null, - ), - ), - ), - ], - ), + indicatorBorderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + iconBuilder: (value) => Icon( + locked + ? LucideIcons.lock + : value + ? LucideIcons.check + : LucideIcons.pause, + size: 15.sp, + color: ColorConstants.primary, + ), + textBuilder: (value) => Padding( + padding: EdgeInsets.symmetric(horizontal: 2.w), + child: Text( + value ? 'On duty' : 'Off duty', + maxLines: 1, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: Colors.white, + fontFamily: FontConstants.fontFamily, ), ), ), + // The confirmation sheet owns the decision — see + // [_confirmOnlineOffline]. The switch never changes duty by itself. + onChanged: locked ? null : (_) => _confirmOnlineOffline(), ), ); } @@ -2484,680 +3297,165 @@ class _HomepageState extends State // Offline riders don't receive bookings, so the empty state must explain // *why* it's empty and offer the one action that fixes it — going online. final bool offline = !isOnline; - return Transform.translate( - offset: const Offset(0, -20), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (offline) - // Offline Lottie in its native colors. + + // ── Online, nothing assigned: the art illustrates, the type speaks ── + // + // The artwork ships with its own headline baked into the pixels — "No + // Bookings **Available**" with a red accent word, and a marketing line + // ending in an exclamation mark. Baked type cannot be Poppins, cannot + // scale with the rider's system text size, and reads as a poster pasted + // into the app rather than the app talking. So the image is cropped to + // its illustration — the bottom band where the words live is cut — and + // the message is set below it in the design system's own voice, exactly + // as every other empty state on the app does. + if (!offline) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 28.w), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── The artwork carries its own white, so give it an edge ── + // + // Seen on a device: this asset has a white background baked in, + // and Home's ground is the canvas — so a hard white rectangle + // sat on grey with visible corners, reading as a failed image + // rather than an illustration. + // + // Rounding it turns the asset's own ground into a deliberate + // layer-1 surface. Nothing is masked or recoloured; the picture + // simply stops having square corners on a page that has none. SizedBox( - width: 210.w, - height: 180.w, - child: Lottie.asset( - 'assets/lotties/no_internet.json', - fit: BoxFit.contain, - repeat: true, - ), - ) - else - // "No results" Lottie, tinted to the maroon brand theme. - SizedBox( - width: 210.w, - height: 180.w, - child: ColorFiltered( - colorFilter: ColorFilter.mode( - _primaryRed, - BlendMode.modulate, - ), - child: Lottie.asset( - 'assets/lotties/no_bookings.json', - fit: BoxFit.contain, - repeat: true, + width: 280.w, + child: AspectRatio( + // The illustration occupies the top ~75% of the 3:2 asset; + // everything below that line is the baked copy being + // removed. Top-aligned cover crop keeps scooter, rider and + // pin whole. + aspectRatio: 1536 / 770, + child: ClipRRect( + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), + child: Image.asset( + 'assets/images/no_bookings_available.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ), ), ), ), - SizedBox(height: 12.h), - Text( - offline ? "You're Offline" : "No Bookings Available", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w700, - fontFamily: FontConstants.fontFamily, - color: _textPrimary, + SizedBox(height: 18.h), + Text( + 'No bookings yet', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), ), + SizedBox(height: 6.h), + Text( + 'Stay on duty — new orders appear here the moment ' + 'the hub assigns them.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.5.sp, + height: 1.45, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Offline Lottie in its native colors. + SizedBox( + width: 210.w, + height: 180.w, + child: Lottie.asset( + 'assets/lotties/no_internet.json', + fit: BoxFit.contain, + repeat: true, ), - SizedBox(height: 8.h), - Text( - offline - ? "Go online to start receiving bookings" - : "We'll notify you when a new booking arrives", - textAlign: TextAlign.center, + ), + SizedBox(height: 12.h), + Text( + "You're Offline", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + color: _textPrimary, + ), + ), + SizedBox(height: 8.h), + Text( + "Go online to start receiving bookings", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + fontFamily: FontConstants.fontFamily, + color: _textSecondary, + ), + ), + SizedBox(height: 18.h), + ElevatedButton.icon( + onPressed: _hasActivePickup() ? null : _confirmOnlineOffline, + icon: Icon(LucideIcons.zap, size: 18.sp), + label: Text( + "Go Online", style: TextStyle( fontSize: 14.sp, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, - color: _textSecondary, ), ), - if (offline) ...[ - SizedBox(height: 18.h), - ElevatedButton.icon( - onPressed: _hasActivePickup() ? null : _confirmOnlineOffline, - icon: Icon(Icons.bolt_rounded, size: 18.sp), - label: Text( - "Go Online", - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w700, - fontFamily: FontConstants.fontFamily, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: _primaryRed, - foregroundColor: Colors.white, - elevation: 0, - // Mid-size: content-hugging but with a comfortable min width, - // so it's neither full-width nor a tiny chip. The height is - // the unscaled [ButtonSizes.compact] rather than `44.h` — - // ScreenUtil would shrink a scaled 44 below the tap-target - // floor on a short phone, which is where it matters most. - minimumSize: Size(150.w, ButtonSizes.compact), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: EdgeInsets.symmetric( - horizontal: 22.w, - vertical: 11.h, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(ButtonSizes.radius), - ), - ), + style: ElevatedButton.styleFrom( + backgroundColor: _primaryRed, + foregroundColor: Colors.white, + elevation: 0, + // Mid-size: content-hugging but with a comfortable min width, + // so it's neither full-width nor a tiny chip. The height is + // the unscaled [ButtonSizes.compact] rather than `44.h` — + // ScreenUtil would shrink a scaled 44 below the tap-target + // floor on a short phone, which is where it matters most. + minimumSize: Size(150.w, ButtonSizes.compact), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 11.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), ), - ], - ], - ), - ), - ); - } - - Widget _buildBookingCard(String orderId, Map item) { - // First-mile milk-run: this card is a single pickup stop on the route. - final title = (item['pickupcustomer'] ?? 'Pickup point').toString(); - final address = (item['pickupaddress'] ?? 'Pickup address unavailable') - .toString(); - final int quantity = - int.tryParse('${item['Quantity'] ?? item['quantity'] ?? 0}') ?? 0; - final isSelected = _selectedOrders[orderId] ?? false; - final currentStatus = _getStatusFromOrder(item); - - return Container( - key: ValueKey('order_$orderId'), - margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 5.h), - decoration: BoxDecoration( - color: _cardColor, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: Colors.black.withValues(alpha: 0.05), - width: 1, - ), - boxShadow: const [ - BoxShadow( - color: Color(0x0D000000), - blurRadius: 24, - offset: Offset(0, 8), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(16.r, 12.r, 16.r, 2.r), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header: type badge + order id + select ── - Row( - children: [ - Container( - padding: EdgeInsets.symmetric( - horizontal: 9.w, - vertical: 4.h, - ), - decoration: BoxDecoration( - color: _primaryRed.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.inventory_2_rounded, - size: 12.sp, - color: _primaryRed, - ), - SizedBox(width: 5.w), - Text( - 'PICKUP', - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.6, - color: _primaryRed, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ), - SizedBox(width: 8.w), - Expanded( - child: Text( - '#$orderId', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - color: _textTertiary, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - GestureDetector( - onTap: _hasActivePickup() - ? null - : () { - setState(() { - _selectedOrders[orderId] = !isSelected; - isAllSelected = _selectedOrders.values.every( - (s) => s, - ); - }); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 24.w, - height: 24.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: isSelected ? _primaryRed : Colors.transparent, - border: Border.all( - color: isSelected - ? _primaryRed - : ColorConstants.outlineVariant, - width: 2, - ), - shape: BoxShape.circle, - ), - child: isSelected - ? Icon( - Icons.check, - color: Colors.white, - size: 15.sp, - ) - : null, - ), - ), - ], - ), - SizedBox(height: 10.h), - // ── Identity: store icon + name + address ── - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 42.w, - height: 42.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _primaryRed.withValues(alpha: 0.09), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), - child: Icon( - Icons.storefront_rounded, - color: _primaryRed, - size: 21.sp, - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 16.5.sp, - height: 1.15, - color: _textPrimary, - fontFamily: FontConstants.fontFamily, - letterSpacing: -0.3, - ), - ), - SizedBox(height: 5.h), - Row( - children: [ - Icon( - Icons.location_on_rounded, - size: 14.sp, - color: _textTertiary, - ), - SizedBox(width: 3.w), - Expanded( - child: Text( - address, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - height: 1.3, - color: _textSecondary, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - SizedBox(height: 10.h), - // ── Info chip(s) ── - Row( - children: [ - Container( - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 6.h, - ), - decoration: BoxDecoration( - color: ColorConstants.neutralLight, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.inventory_2_outlined, - size: 14.sp, - color: _textSecondary, - ), - SizedBox(width: 6.w), - Text( - quantity > 0 ? '$quantity items' : 'Parcel', - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w600, - color: _textPrimary, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ), - ], - ), - // Call + Details moved into the single action bar below - // (OrderStatusRow) so the card no longer stacks two full-width - // button rows. See leadingActions on OrderStatusRow. - ], ), ), - Row( - children: [ - Expanded( - child: OrderStatusRow( - key: ValueKey('status_${orderId}_$currentStatus'), - currentStatus: currentStatus, - // Secondary actions folded into the same action bar as - // Reject/Accept so the card shows ONE row of controls, not two. - onCall: () async { - final phone = (item['pickupcontactno'] ?? '') - .toString() - .trim(); - if (phone.isEmpty) { - if (!context.mounted) return; - AppFeedback.error(context, 'Contact number not available'); - return; - } - await launchPhoneDialer(phone); - }, - onDetails: () => _showProductDetailsSheet(context, item), - // Enable single-booking actions whenever the rider is NOT in - // multi-select mode (0 selected, or only this one selected). - // Previously this required ticking the checkbox first, so a - // plain tap on Accept did nothing. - enabled: (() { - final selectedCount = _selectedOrders.values - .where((s) => s) - .length; - return (selectedCount == 0 || - (selectedCount == 1 && isSelected)) && - !(_statusBusy[orderId] ?? false); - })(), - onStatusChange: (newStatus, {String? notes, String? proofImagePath}) async { - debugPrint( - '[SINGLE] Booking $orderId: $currentStatus -> $newStatus', - ); - if (_statusBusy[orderId] == true) return false; - if (mounted) setState(() => _statusBusy[orderId] = true); - - final pickupId = - int.tryParse('${item['pickupid'] ?? 0}') ?? 0; - final orderHeaderId = - int.tryParse('${item['orderheaderid'] ?? 0}') ?? 0; - final pickupLocationId = - int.tryParse('${item['pickuplocationid'] ?? 0}') ?? 0; - - final dc = Get.find(); - bool success = false; - try { - if (newStatus == 'ACCEPTED') { - debugPrint( - '[SINGLE] updateAcceptedStatus pickupId=$pickupId', - ); - success = await dc.updateAcceptedStatus( - pickupId: pickupId, - orderHeaderId: orderHeaderId, - notes: notes ?? '', - ); - } else if (newStatus == 'ARRIVED') { - debugPrint( - '[SINGLE] updateArrivedStatus pickupId=$pickupId', - ); - final pickupLat = _parseDouble( - item['pickuplat'] ?? item['PickupLat'] ?? 0, - ); - final pickupLng = _parseDouble( - item['pickuplon'] ?? item['PickupLon'] ?? 0, - ); - final riderLoc = await _getValidCoordinates(); - success = await dc.updateArrivedStatus( - pickupId: pickupId, - orderHeaderId: orderHeaderId, - ridersLat: riderLoc?.$1 ?? '0', - ridersLng: riderLoc?.$2 ?? '0', - pickupLat: pickupLat.toString(), - pickupLng: pickupLng.toString(), - notes: notes ?? '', - ); - if (success) { - if (mounted) { - final verifyResult = - await openSheet>( - context, - StopVerificationPage(pickup: item), - ); - if (verifyResult != null && - verifyResult['verified'] == true && - mounted) { - // Only stand between the rider and the next stop - // when there is actually money owed. The old - // screen opened on every stop and invented an - // amount from the typed weight, so a prepaid - // consignment still demanded ₹30. - if (stopCollectionAmount(item) > 0) { - await openSheet>( - context, - CollectPaymentPage( - pickup: item, - verificationData: verifyResult, - ), - ); - } - if (!mounted) return false; - String? proofImageUrl; - if (verifyResult['parcelImage'] != null) { - try { - proofImageUrl = await dc.uploadProofImage( - File(verifyResult['parcelImage']), - 'picked', - (await SharedPreferences.getInstance()) - .getInt('userid') ?? - 0, - pickupId, - ); - } catch (e) { - debugPrint('Error uploading proof image: $e'); - } - } - final verifyPickupLat = _parseDouble( - item['pickuplat'] ?? item['PickupLat'] ?? 0, - ); - final verifyPickupLng = _parseDouble( - item['pickuplon'] ?? item['PickupLon'] ?? 0, - ); - final verifyRiderLoc = - await _getValidCoordinates(); - await dc.updatePickedStatus( - pickupId: pickupId, - orderHeaderId: orderHeaderId, - pickupLocationId: pickupLocationId, - ridersLat: verifyRiderLoc?.$1 ?? '0', - ridersLng: verifyRiderLoc?.$2 ?? '0', - pickupLat: verifyPickupLat.toString(), - pickupLng: verifyPickupLng.toString(), - notes: '', - proofImage: proofImageUrl, - ); - if (mounted) { - _startPickupPosting(item); - setState(() { - _hiddenOrderIds.add(orderId); - _ordersMap.remove(orderId); - _orderIds.remove(orderId); - _selectedOrders.remove(orderId); - _statusBusy.remove(orderId); - }); - await _fetchQueues(); - dc.triggerRefresh(); - } - } - } - } - } else if (newStatus == 'PICKED') { - debugPrint( - '[SINGLE] updatePickedStatus pickupId=$pickupId', - ); - String? proofImageUrl; - if (proofImagePath != null) { - try { - proofImageUrl = await dc.uploadProofImage( - File(proofImagePath), - 'picked', - (await SharedPreferences.getInstance()).getInt( - 'userid', - ) ?? - 0, - pickupId, - ); - debugPrint( - '[SINGLE] Picked Proof URL: $proofImageUrl', - ); - } catch (e) { - debugPrint('Error uploading proof image: $e'); - } - } - final pickupLat = _parseDouble( - item['pickuplat'] ?? item['PickupLat'] ?? 0, - ); - final pickupLng = _parseDouble( - item['pickuplon'] ?? item['PickupLon'] ?? 0, - ); - final riderLoc = await _getValidCoordinates(); - success = await dc.updatePickedStatus( - pickupId: pickupId, - orderHeaderId: orderHeaderId, - pickupLocationId: pickupLocationId, - ridersLat: riderLoc?.$1 ?? '0', - ridersLng: riderLoc?.$2 ?? '0', - pickupLat: pickupLat.toString(), - pickupLng: pickupLng.toString(), - notes: notes ?? '', - proofImage: proofImageUrl, - ); - if (success) { - _startPickupPosting(item); - _hiddenOrderIds.add(orderId); - // A picked booking is done — drop it from the accepted - // store so it doesn't resurface as an open booking. - await removeAcceptedBookings([orderId]); - } - } else if (newStatus == 'REJECTED') { - debugPrint( - '[SINGLE] updateRejectedStatus pickupId=$pickupId notes=${notes ?? ''}', - ); - success = await dc.updateRejectedStatus( - pickupId: pickupId, - orderHeaderId: orderHeaderId, - notes: notes ?? '', - ); - } else if (newStatus == 'CANCELLED') { - debugPrint( - '[SINGLE] updateCancelledStatus pickupId=$pickupId notes=${notes ?? ''}', - ); - final pickupLat = _parseDouble( - item['pickuplat'] ?? item['PickupLat'] ?? 0, - ); - final pickupLng = _parseDouble( - item['pickuplon'] ?? item['PickupLon'] ?? 0, - ); - final riderLoc = await _getValidCoordinates(); - success = await dc.updateCancelledStatus( - pickupId: pickupId, - orderHeaderId: orderHeaderId, - ridersLat: riderLoc?.$1 ?? '0', - ridersLng: riderLoc?.$2 ?? '0', - pickupLat: pickupLat.toString(), - pickupLng: pickupLng.toString(), - notes: notes ?? '', - ); - if (success && mounted) { - setState(() { - _hiddenOrderIds.add(orderId); - _ordersMap.remove(orderId); - _orderIds.remove(orderId); - _selectedOrders.remove(orderId); - _statusBusy.remove(orderId); - }); - } - } - - // Accepting a pending booking always routes the rider to - // their Bookings tab (where the accepted pickup lives), - // regardless of the network result — the queues reconcile - // on the next poll. This mirrors the app's optimistic UX. - if (newStatus == 'ACCEPTED') { - if (success) dc.triggerRefresh(); - // Persist acceptance so the booking durably moves to the - // Bookings tab (and stays off Home) across refetches — - // the backend keeps it 'Miler_Assigned' until pickup, so - // the local accepted store is what marks it accepted. - await addAcceptedBookings([item]); - if (mounted) { - setState(() { - // Remove from Home right away so the pending list - // and the "Active Tasks" count drop immediately. - _hiddenOrderIds.add(orderId); - _ordersMap.remove(orderId); - _orderIds.remove(orderId); - _selectedOrders.remove(orderId); - _statusBusy.remove(orderId); - }); - // Stay on Home so the rider can keep accepting the - // remaining pending bookings back-to-back. The accepted - // one already dropped out of the list above and now - // lives in the Bookings tab. - AppFeedback.successGlobal('Saved to your Bookings — accept more here'); - } - return success; - } - - // Rejecting a pending booking removes it from Home right - // away regardless of the network result (mirrors accept's - // optimistic UX); persist it so it stays gone across - // refetch/navigation until the demo resets. - if (newStatus == 'REJECTED') { - if (success) dc.triggerRefresh(); - await addRejectedOrderIds([orderId]); - if (mounted) { - setState(() { - _hiddenOrderIds.add(orderId); - _ordersMap.remove(orderId); - _orderIds.remove(orderId); - _selectedOrders.remove(orderId); - _statusBusy.remove(orderId); - }); - AppFeedback.success(context, 'Booking rejected'); - } - return success; - } - - if (success) { - dc.triggerRefresh(); - if (newStatus == 'PICKED') { - if (mounted) { - setState(() { - _hiddenOrderIds.add(orderId); - _ordersMap.remove(orderId); - _orderIds.remove(orderId); - _selectedOrders.remove(orderId); - _statusBusy.remove(orderId); - }); - } - await _fetchQueues(); - } else { - if (mounted) { - setState(() { - final Booking = _ordersMap[orderId]; - if (Booking != null) { - Booking['orderstatus'] = newStatus - .toLowerCase(); - } - _statusBusy[orderId] = false; - }); - } - await _fetchQueues(); - if (mounted && newStatus == 'ARRIVED') { - AppFeedback.success(context, 'Arrived at pickup location'); - } - } - } else { - debugPrint( - '[SINGLE] API call failed for Booking $orderId', - ); - if (mounted) { - setState(() => _statusBusy[orderId] = false); - AppFeedback.error(context, 'Could not update the status — try again'); - } - } - } catch (e) { - debugPrint( - '[SINGLE] Error updating Booking $orderId: $e', - ); - if (mounted) { - setState(() => _statusBusy[orderId] = false); - AppFeedback.error(context, 'Could not update the status — try again'); - } - } - return success; - }, - ), - ), - ], - ), ], ), ); } + // ── `_buildBookingCard` is gone ── + // + // A ~600-line alternate Home, unreachable: no call site anywhere in the app. + // It drew a stop as a bordered, shadowed, rounded card with its own status + // pill, its own accept/reject pair and its own address treatment — a second + // and contradictory visual system for the same object the route node now + // draws, sitting in the production file waiting for somebody to wire it back + // up because it looked finished. + // + // Nothing was salvaged from it. Every helper it called is still used by the + // live composition; it called no logic of its own that the route timeline + // does not already own. Widget _buildActionButton({ required IconData icon, @@ -3208,17 +3506,13 @@ class _HomepageState extends State child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.flash_on_rounded, - color: Colors.white, - size: 20.sp, - ), + Icon(LucideIcons.zap, color: Colors.white, size: 20.sp), SizedBox(width: 8.w), Text( '${_getBulkButtonLabel()} (${selectedCount})', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, color: Colors.white, letterSpacing: 0.3, @@ -3237,15 +3531,12 @@ class _HomepageState extends State @override Widget build(BuildContext context) { super.build(context); - // Home is trip-shaped: the hub assigns a whole slot, not loose bookings. - final trips = _trips; - // Trip 2 and Trip 3 exist as tabs before the hub assigns them, so the index - // is clamped to the TAB count, not the trip count — an index past the end - // is a legitimate "not assigned yet" slot, not an error. - final tabCount = trips.length > TripTabs.maxInlineTabs - ? trips.length - : TripTabs.maxInlineTabs; - final selectedTrip = _selectedTripIndex.clamp(0, tabCount - 1); + // The day, grouped into the slots the hub assigned — see [_trips]. The tab + // bar below chooses one of them; [trip] is null when that slot is still + // empty, which is its own screen and not "no work". + final List trips = _trips; + final Trip? trip = _selectedTrip; + final bool hasWork = trips.isNotEmpty; return PopScope( canPop: false, @@ -3267,7 +3558,7 @@ class _HomepageState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textPrimary, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), ), content: Text( @@ -3346,200 +3637,365 @@ class _HomepageState extends State // below it, so the spinner already comes down into clear space // instead of out from behind the status bar. Expanded( - child: RefreshIndicator( - onRefresh: _handleRefresh, + // ── The corner has to have something behind it ── + // + // The sheet was clipping straight onto the Scaffold, whose + // background is this same `_surfaceColor` — so the two + // rounded corners revealed the page's own colour and the + // curve was invisible. Home read as a hard maroon line above + // a form while the other three tabs had the shape. + // + // The other three get theirs from `backgroundColor: + // primary` on the Scaffold; Home cannot, because its Stack + // paints other things on that ground. So the brand is laid + // directly behind the sheet instead — the same result, scoped + // to the strip that needs it. + child: ColoredBox( color: ColorConstants.primary, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // ══════════════════════════════════════════════════════════ - // READING ORDER - // - // [app bar] fixed - // shift window scrolls away - // trip summary scrolls away (ring, slot, load, metrics) - // ─ TRIP 1 · 2 · 3 ─ PINNED - // hint line scrolls - // HUB → stops → HUB scrolls - // accept bar scrolls - // - // The tab bar is the only thing between the summary and the stops, - // and it is the only pinned sliver. Everything above it — the - // shift window, the completion ring, the duration and distance — - // is reference material the rider reads once and then works from - // memory, so it is allowed to leave. The tabs are a control that - // governs the whole screen, so they stay. See [TripTabsHeader]. - // ══════════════════════════════════════════════════════════ + child: MilerSheet( + color: _surfaceColor, + child: RefreshIndicator( + onRefresh: _handleRefresh, + color: ColorConstants.primary, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // ══════════════════════════════════════════════════════════ + // READING ORDER + // + // [app bar] fixed + // shift window scrolls away + // trip summary scrolls away (ring, slot, load, metrics) + // ─ TRIP 1 · 2 · 3 ─ PINNED + // hint line scrolls + // HUB → stops → HUB scrolls + // accept bar scrolls + // + // The tab bar is the only thing between the summary and the stops, + // and it is the only pinned sliver. Everything above it — the + // shift window, the completion ring, the duration and distance — + // is reference material the rider reads once and then works from + // memory, so it is allowed to leave. The tabs are a control that + // governs the whole screen, so they stay. See [TripTabsHeader]. + // ══════════════════════════════════════════════════════════ - // The trip brief used to sit here, above the tabs. It now sits - // below them, inside the per-trip block — see the note at its new - // call site. - // - // The standalone shift-window strip that used to sit above it is - // gone too: it showed the rider's shift while the card below it - // showed the *trip's* slot under the caption "CURRENT SHIFT", so - // two different time ranges sat stacked with one wearing the - // other's name. Both now live inside [TripBriefStrip] as adjacent - // labelled rows. See that class for the rest of the reasoning. + // The trip brief used to sit here, above the tabs. It now sits + // below them, inside the per-trip block — see the note at its new + // call site. + // + // The standalone shift-window strip that used to sit above it is + // gone too: it showed the rider's shift while the card below it + // showed the *trip's* slot under the caption "CURRENT SHIFT", so + // two different time ranges sat stacked with one wearing the + // other's name. Both now live inside [TripBriefStrip] as adjacent + // labelled rows. See that class for the rest of the reasoning. - // ── Trip 1 · Trip 2 · Trip 3, pinned ── - SliverPersistentHeader( - pinned: true, - delegate: TripTabsHeader( - extent: TripTabsHeader.extentFor(context), - trips: trips, - selectedIndex: selectedTrip, - acceptedIds: _acceptedIds, - rejectedIds: _rejectedIds, - onSelect: (i) => setState(() { - _selectedTripIndex = i; - // Selection belongs to one trip; carrying ticks across - // tabs would let an "accept selected" touch stops the - // rider can no longer see. - _selectedStopIds = {}; - }), - ), + // ── Trip 1 · 2 · 3, pinned ── + // + // Only shown once the day has at least one trip in it: with + // nothing assigned the bar would be three empty controls above a + // "you have no bookings" panel, which reads as a broken screen + // rather than an idle one. + if (hasWork) + SliverPersistentHeader( + pinned: true, + delegate: TripTabsHeader( + trips: trips, + selectedIndex: _selectedTripIndex, + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + extent: TripTabsHeader.extentFor(context), + // Selection is per-trip, so the ticks do not travel + // with the rider between tabs — a tick made on Trip 1 + // must not be counted by the bar while Trip 2 is on + // screen. See [_selectedPendingIds]. + onSelect: (index) { + if (index == _selectedTripIndex) return; + setState(() { + _selectedTripIndex = index; + _selectedStopIds.clear(); + }); + }, + ), + ), + + // The stops, an empty state for an unassigned slot, or — until + // the first fetch lands — the shape of a route being loaded. + // + // One swap holding all of them, so the hand-off between them is + // a cross-fade instead of one sliver being swapped for another + // and the screen going briefly blank in between. + // + // ── Why the sliver type changes with the state ── + // + // With nothing to list, the message *is* the page, and it + // belongs in the middle of it. A box adapter shrink-wraps its + // child, so a Center inside one only centres within the + // artwork's own height — which parked it under the app bar at + // the top of an otherwise blank screen. [SliverFillRemaining] + // hands it the rest of the viewport to be centred in, and + // `hasScrollBody: false` still lets it grow past that on a + // short phone rather than clipping. + // + // A real route keeps the adapter: filling the remainder there + // would pad every short trip with a screen of dead space above + // the floating-pill floor. + if (!hasWork) + SliverFillRemaining( + hasScrollBody: false, + child: SmoothSwap( + child: !_queuesLoaded + // The real timeline in grey, not three + // generic cards — see [HomeRouteSkeleton]. + ? const HomeRouteSkeleton( + key: ValueKey('home-loading'), + ) + : _fetchFailed + ? Padding( + key: const ValueKey('home-error'), + padding: EdgeInsets.symmetric( + vertical: 32.h, + ), + child: ErrorRetry( + onRetry: _handleRefresh, + ), + ) + // ── A day with no work says so ── + // + // This rendered `SizedBox.shrink()` — + // literally nothing — so a rider whose hub + // had not assigned him anything opened Home + // to a blank screen and could not tell it + // apart from a screen that had failed to + // load. The empty state already existed and + // had simply been orphaned when the trip tabs + // came out; it states which of the two this + // is, and offers the one action that changes + // it when he is off duty. + : KeyedSubtree( + key: const ValueKey('home-empty'), + child: _buildEmptyState(), + ), + ), + ) + else + SliverToBoxAdapter( + child: SmoothSwap( + // ── A slot the hub has not filled ── + // + // The day has work, just not on this tab. Says so + // by name rather than reusing the whole-day empty + // state, which would claim he has nothing to do + // while another trip is running. + child: trip == null + ? KeyedSubtree( + key: ValueKey( + 'trip-empty-$_selectedTripIndex', + ), + child: _buildTripEmptyState( + _selectedTripIndex, + ), + ) + : Reveal( + // Keyed on the trip, so switching tabs plays + // the entrance once and the 5s poll never + // replays it. + key: ValueKey('reveal_${trip.id}'), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + // The brief sits UNDER the tabs, not above. + // + // It describes one trip, and the tabs are what + // choose that trip — so above them it was + // answering a question the rider had not been + // asked yet, and switching tabs changed a + // panel he had already scrolled past. Below + // them it reads as the selected tab's own + // content, which is what it is, and it lands + // inside the per-trip [Reveal] so it swaps + // with the route it belongs to. + _tripCardFor(trip).buildSummary(), + // The "one thing to do now" head that + // used to sit here is gone. It drew the + // current place, its load and a + // full-width **Navigate to pickup** — + // all of which the timeline's own group + // node below already carries, button + // included. Two identical red buttons + // for one destination was the single + // largest thing on this screen; the + // node that owns the journey owns the + // action. + _tripCardFor(trip).buildStops(), + _tripCardFor( + trip, + ).buildActionBar(), + ], + ), + ), + ), + ), + + // ── Room for everything that floats over this list ── + // + // 80 was measured against the accepted pill alone. Two + // more things pin to the same corner — the selection + // bar and the live-pickup banner — and with a banner up + // the last stop card was covered by it with no way to + // scroll clear, which is what a rider reported as "the + // card is cut off". + // + // The floor now clears the nav bar plus the tallest + // stack that can sit on it, so the end of the route is + // always reachable. + // ── Measured, not assumed ── + // + // This was a flat `80.h`. The two surfaces that can + // occupy the bottom slot now declare their own + // heights, and the floor reserves the taller of the + // two plus the device inset — so the last route row + // clears whichever one is showing, at any text + // scale, without anybody re-measuring by eye. + SliverToBoxAdapter( + child: SizedBox( + height: + (SelectionBar.maxHeight > + AcceptedPill.maxHeight + ? SelectionBar.maxHeight + : AcceptedPill.maxHeight) + + BottomPage.bottomInset(context), + ), + ), + ], ), - - // The stops, an empty state for an unassigned slot, or — until - // the first fetch lands — the shape of a route being loaded. - // - // One adapter holding all three, so the hand-off between them is - // a cross-fade instead of one sliver being swapped for another - // and the screen going briefly blank in between. - SliverToBoxAdapter( - child: SmoothSwap( - child: trips.isEmpty && !_queuesLoaded - ? const SkeletonList( - key: ValueKey('home-loading'), - count: 3, - ) - : trips.isEmpty && _fetchFailed - ? Padding( - key: const ValueKey('home-error'), - padding: EdgeInsets.symmetric( - vertical: 32.h, - ), - child: ErrorRetry(onRetry: _handleRefresh), - ) - : selectedTrip >= trips.length - ? KeyedSubtree( - key: ValueKey('home-empty-$selectedTrip'), - child: _buildTripEmptyState(selectedTrip), - ) - : Reveal( - // Keyed on the trip so switching tabs plays the - // entrance once per trip, and the 5s poll never - // replays it. - key: ValueKey( - 'reveal_trip_${trips[selectedTrip].id}', - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - // The brief sits UNDER the tabs, not above. - // - // It describes one trip, and the tabs are what - // choose that trip — so above them it was - // answering a question the rider had not been - // asked yet, and switching tabs changed a - // panel he had already scrolled past. Below - // them it reads as the selected tab's own - // content, which is what it is, and it lands - // inside the per-trip [Reveal] so it swaps - // with the route it belongs to. - _tripCardFor( - trips[selectedTrip], - ).buildSummary(), - _tripCardFor( - trips[selectedTrip], - ).buildStops(), - _tripCardFor( - trips[selectedTrip], - ).buildActionBar(), - ], - ), - ), - ), - ), - - // 6. Bottom padding for bulk action button - SliverToBoxAdapter(child: SizedBox(height: 80.h)), - ], + ), ), ), ), ], ), - // ── The bottom overlays, stacked rather than layered ── + // ── The bottom overlay ── // - // Both of these pin to the foot of the screen, and before this they - // were separate `Positioned(bottom: 0)` children — which meant that - // the moment a rider with a live pickup ticked a stop, one covered - // the other. A single bottom-anchored column lets them coexist: the - // selection bar takes the lower slot because it is the thing the - // rider is acting on right now, and the ambient banner rides above. + // Only one thing floats over the foot of Home now. The red "Active + // Pickup" strip that used to share this column is gone: it was a + // second object describing a stop the list below was hiding, it + // covered the last card, and its tap did nothing but refetch. The + // live stop is a row in the trip now, marked LIVE and carrying its + // own way back in — see `TripCard._showsOnHome`. Positioned( left: 0, right: 0, bottom: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_activePickups.isNotEmpty) - SafeArea( - top: false, - bottom: _selectionCount == 0, - child: ActivePickupBanner( - activePickups: _activePickups, - onTap: (pickup) async { - await _navigateToActivePickup(pickup); - if (mounted) { - await Future.delayed( - const Duration(milliseconds: 500), - ); - final prefs = await SharedPreferences.getInstance(); - final userId = prefs.getInt('userid'); - if (userId != null) { - await _fetchQueues(); - } - } + // ── The command surface arrives, it does not appear ── + // + // Ticking a row swaps one floating object for another in the same + // slot, and swapping them in a single frame reads as the page + // flickering rather than as a mode changing. A short rise-and-fade + // — the app's own state duration, no bounce — is what says "this + // came up because of what you just did". + child: AnimatedSwitcher( + duration: DesignConstants.motionState, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.35), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ), + child: Column( + key: ValueKey(_selectionCount > 0), + mainAxisSize: MainAxisSize.min, + children: [ + if (_selectionCount > 0) + Builder( + builder: (context) { + final action = _selectionAction(); + return SelectionBar( + count: _selectionCount, + action: action, + busy: + _acceptingTripId == 'selection' || + _busyStopIds.isNotEmpty, + onClear: () => + setState(() => _selectedStopIds = {}), + onAccept: _acceptingTripId != null + ? null + : () => _acceptSelectedStops( + _selectedPendingIds().toList(), + ), + // Arrived / picked up run against the whole ticked + // set in one slide — see [_advanceSelection]. + onAdvance: _advanceSelection, + // A stop already taken on cannot be declined from + // here, so Reject leaves with the accept rung. + onReject: + action != SelectionAction.accept || + _acceptingTripId != null + ? null + : () => _rejectSelectedStops( + _selectedPendingIds().toList(), + ), + ); }, - ), - ), - if (_selectionCount > 0) - SelectionBar( - count: _selectionCount, - busy: _acceptingTripId == 'selection', - onClear: () => - setState(() => _selectedStopIds = {}), - onAccept: _acceptingTripId != null - ? null - : () => _acceptSelectedStops( - _selectedPendingIds().toList(), - ), - onReject: _acceptingTripId != null - ? null - : () => _rejectSelectedStops( - _selectedPendingIds().toList(), - ), - ) - else - // Only when nothing is ticked. The two share this slot on - // purpose — see [AcceptedPill] — and a live selection is the - // more urgent of the two. - SafeArea( - top: false, - child: AcceptedPill( - count: _acceptedStops.length, - // Moves the shell's IndexedStack rather than rebuilding - // it, so Bookings keeps its state and the jump lands. + ) + // ── The hinge is not here ── + // + // START DELIVERY used to sit in this slot, and it was in the + // wrong place. Home is the *collection* workspace: a stop + // leaves it the moment it is in the rider's hands. So the + // control that starts the round lived on a screen the round's + // orders had already left — and the count it showed was the + // only trace of them anywhere in the app. + // + // It belongs where the load is, which is the deliveries tab. + // See the bar at the foot of `pickups.dart`. + else + // Only when nothing is ticked. The two share this slot on + // purpose — see [AcceptedPill] — and a live selection is + // the more urgent of the two. + // ── No SafeArea here ── + // + // [BottomPage.bottomInset] already includes + // `MediaQuery.padding.bottom`, so wrapping the pill in a + // `SafeArea` added the gesture bar's inset a second time + // and floated it ~34pt clear of the nav on the phones + // that have one. The inset is owned in one place; a + // second opinion about it is what makes a floating + // control drift between devices. + AcceptedPill( + // ── Derived, not counted twice ── + // + // This read `_acceptedStops.length` — the length of a + // persisted list of accepted bookings. That store is + // written on accept and cleaned on completion, so any + // stop that left the day some other way (the hub + // pulling it, a reject that raced a refetch, a run + // that simply ended) stayed in it, and the pill + // counted work the rider no longer had. It is a + // second source of truth for a number the trip + // already knows. + // + // The trip is that source: today's stops, resolved + // against the same accepted/rejected sets every other + // surface on this screen reads. Accepting two shows + // two; rejecting one drops it in the same frame, + // because the set it is derived from changed. + count: _acceptedWaitingCount, + // Moves the shell's IndexedStack rather than + // rebuilding it, so Bookings keeps its state and the + // jump lands. onTap: () => BottomPage.goToTab(1), ), - ), - ], + ], + ), ), ), @@ -3559,346 +4015,11 @@ class _HomepageState extends State // ==================== BOTTOM SHEETS ==================== -void _showmapDetailsSheet( - BuildContext context, - Map? item, -) async { - final Color primaryRed = ColorConstants.primary; - final Color lightRed = ColorConstants.errorLight; - - double parseD(dynamic v) { - if (v == null) return 0.0; - if (v is num) return v.toDouble(); - return double.tryParse(v.toString()) ?? 0.0; - } - - final String pickupLatStr = (item?['pickuplat'] ?? item?['PickupLat'] ?? '') - .toString(); - final String pickupLngStr = (item?['pickuplon'] ?? item?['PickupLon'] ?? '') - .toString(); - final String orderId = (item?['orderid'] ?? '').toString(); - final String statusStr = (item?['orderstatus'] ?? '').toString(); - final String pickupAddress = - (item?['Pickupaddress'] ?? item?['pickuplocation'] ?? '').toString(); - - double pickupLat = parseD(pickupLatStr); - double pickupLng = parseD(pickupLngStr); - bool hasPickup = pickupLat != 0 && pickupLng != 0; - - double riderLat = 0.0; - double riderLng = 0.0; - bool hasRiderLocation = false; - - try { - Position? pos; - try { - pos = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - timeLimit: const Duration(seconds: 8), - ); - } catch (e) { - pos = await Geolocator.getLastKnownPosition(); - } - if (pos != null) { - riderLat = pos.latitude; - riderLng = pos.longitude; - hasRiderLocation = true; - } - } catch (e) {} - - double distanceMeters = 0.0; - String distanceKm = '0.00'; - if (hasRiderLocation && hasPickup) { - distanceMeters = Geolocator.distanceBetween( - riderLat, - riderLng, - pickupLat, - pickupLng, - ); - distanceKm = (distanceMeters / 1000).toStringAsFixed(2); - } - - final bool hasAny = hasRiderLocation && hasPickup; - - final List polylines = []; - final MapController mapController = MapController(); - - if (hasRiderLocation && hasPickup) { - // OSRM instead of the Directions API — free, and no key to be revoked. - final coords = await MilerRouter.route( - LatLng(riderLat, riderLng), - LatLng(pickupLat, pickupLng), - ); - polylines.add( - Polyline( - // A straight line when the router cannot be reached, so the map still - // says which way the stop is rather than showing nothing at all. - points: coords.isNotEmpty - ? coords - : [LatLng(riderLat, riderLng), LatLng(pickupLat, pickupLng)], - color: primaryRed, - strokeWidth: 5, - strokeCap: StrokeCap.round, - strokeJoin: StrokeJoin.round, - pattern: coords.isNotEmpty - ? const StrokePattern.solid() - : StrokePattern.dashed(segments: const [20, 12]), - ), - ); - } - - showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) { - return Container( - margin: const EdgeInsets.only(top: 50), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 20, - offset: const Offset(0, -8), - ), - ], - ), - child: Padding( - padding: EdgeInsets.only( - left: 24, - right: 24, - top: 12, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 56, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: ColorConstants.outlineVariant, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Trip Map", - style: TextStyle( - fontSize: 22.sp, - fontWeight: FontWeight.bold, - color: ColorConstants.onSurface, - fontFamily: FontConstants.fontFamily, - ), - ), - Text( - orderId.isNotEmpty - ? 'Booking: #$orderId' - : 'Booking: -', - style: TextStyle( - color: ColorConstants.onSurfaceVariant, - fontSize: 14.sp, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - InkWell( - onTap: () => Navigator.of(context).pop(), - child: Icon( - Icons.cancel_rounded, - size: 36, - color: primaryRed, - ), - ), - ], - ), - const SizedBox(height: 12), - - if (hasAny) ...[ - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: lightRed, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: primaryRed.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Icon(Icons.location_on, color: primaryRed, size: 22), - const SizedBox(width: 10), - Expanded( - child: Text( - hasPickup - ? "Pickup: ${pickupAddress.isNotEmpty ? pickupAddress : 'Current location'}" - : 'Pickup: Not available', - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w500, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.onSurface, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 8), - - if (distanceKm != '0.00') - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: primaryRed.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: primaryRed.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Icon(Icons.navigation, color: primaryRed, size: 22), - const SizedBox(width: 10), - Expanded( - child: Text( - "Distance to Pickup: $distanceKm km", - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w500, - fontFamily: FontConstants.fontFamily, - color: primaryRed, - ), - ), - ), - ], - ), - ), - if (distanceKm != '0.00') const SizedBox(height: 12), - - ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - child: Container( - height: 280, - decoration: BoxDecoration( - border: Border.all(color: ColorConstants.dividerColor), - ), - child: MilerMap( - controller: mapController, - initialCenter: hasRiderLocation && hasPickup - ? LatLng( - (riderLat + pickupLat) / 2, - (riderLng + pickupLng) / 2, - ) - : hasPickup - ? LatLng(pickupLat, pickupLng) - : hasRiderLocation - ? LatLng(riderLat, riderLng) - : const LatLng(0, 0), - initialZoom: hasRiderLocation && hasPickup ? 13 : 15, - polylines: polylines, - markers: [ - if (hasPickup) - milerMarker( - point: LatLng(pickupLat, pickupLng), - width: MilerPin.size + 8, - height: MilerPin.size + MilerPin.tail, - child: MilerPin( - color: ColorConstants.primary, - icon: Icons.inventory_2_rounded, - emphasised: true, - ), - ), - if (hasRiderLocation) - milerMarker( - point: LatLng(riderLat, riderLng), - width: MilerRiderDot.size, - height: MilerRiderDot.size, - child: const MilerRiderDot(), - ), - ], - onReady: () { - // ── The custom Google map style is gone with the plugin ── - // - // Forty lines of JSON greyed out POIs and tinted the - // roads, on a map that never rendered because the key was - // revoked. OSM's own cartography is already quiet, and - // restyling raster tiles is not something a client can do - // — that is a tile-server decision now. - if (!hasRiderLocation || !hasPickup) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - mapController.fitCamera( - CameraFit.coordinates( - coordinates: [ - LatLng(riderLat, riderLng), - LatLng(pickupLat, pickupLng), - ], - padding: const EdgeInsets.all(56), - ), - ); - }); - }, - ), - ), - ), - const SizedBox(height: 12), - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: lightRed, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: primaryRed.withValues(alpha: 0.2), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Booking Details", - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.bold, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.onSurface, - ), - ), - const SizedBox(height: 10), - _buildDetailRow( - "Booking ID:", - orderId.isNotEmpty ? "#$orderId" : '-', - ), - _buildDetailRow( - "Status:", - statusStr.isNotEmpty ? statusStr : '-', - ), - _buildDetailRow("Distance:", "$distanceKm km"), - _buildDetailRow( - "Quantity:", - "${item?['Quantity'] ?? item?['quantity'] ?? '-'}", - ), - ], - ), - ), - ], - ], - ), - ), - ); - }, - ); -} +// The "Trip Map" sheet (`_showmapDetailsSheet`) that lived here is deleted: +// zero callers — its job (map + booking facts for one stop) has belonged to +// [StopDetailSheet] since the timeline rework, and it still carried the +// pre-redesign idiom: red-tinted bordered boxes, a 36pt cancel icon, and +// 'Booking: #id' as the subtitle of a sheet a rider opens to see a map. Widget _buildDetailRow(String label, String value) { final Color primaryRed = ColorConstants.primary; @@ -3933,7 +4054,7 @@ Widget _buildDetailRow(String label, String value) { value.contains('Accepted') ? primaryRed : ColorConstants.onSurface, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, height: 1.35, @@ -3949,9 +4070,6 @@ void _showProductDetailsSheet( BuildContext context, [ Map? item, ]) { - final Color primaryRed = ColorConstants.primary; - final Color lightRed = ColorConstants.errorLight; - final data = item ?? const {}; String val(List keys) { @@ -3965,8 +4083,9 @@ void _showProductDetailsSheet( } // Build the rows that actually have data. + // What the parcel IS leads; the reference is last — it is the one line the + // rider never decides anything on, read only when the office asks for it. final rows = >[ - MapEntry('Order ID', val(['orderid', 'OrderId'])), MapEntry( 'Product', val(['description', 'productname', 'product', 'tenantname']), @@ -3981,110 +4100,53 @@ void _showProductDetailsSheet( return a.isEmpty ? '' : '₹$a'; }()), MapEntry('Pickup point', val(['pickupcustomer', 'tenantname'])), - MapEntry('Address:', val(['pickupaddress', 'PickupAddress'])), + MapEntry('Address', val(['pickupaddress', 'PickupAddress'])), MapEntry('Contact', val(['pickupcontactno'])), MapEntry('Notes', val(['notes', 'Notes'])), + MapEntry('Order ID', val(['orderid', 'OrderId'])), ].where((e) => e.value.isNotEmpty).toList(); - showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (context) { - return Padding( - padding: EdgeInsets.only( - left: 24, - right: 24, - top: 20, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Product Details", - style: TextStyle( - fontSize: 22.sp, - fontWeight: FontWeight.bold, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.onSurface, - ), - ), - IconButton( - tooltip: 'Dismiss', - icon: Icon(Icons.cancel, color: primaryRed, size: 32), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 16), - if (rows.isEmpty) - Container( - width: double.infinity, - padding: const EdgeInsets.all(40), - decoration: BoxDecoration( - color: lightRed, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: primaryRed.withValues(alpha: 0.2), - ), - ), - child: Column( - children: [ - Icon( - Icons.inventory_2_rounded, - size: 48, - color: primaryRed.withValues(alpha: 0.3), - ), - const SizedBox(height: 12), - Text( - "No product details available", - style: TextStyle( - fontSize: 16.sp, - color: ColorConstants.onSurfaceVariant, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ) - else - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: ColorConstants.surfaceContainerLow, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all(color: ColorConstants.dividerColor), - ), - child: Column( - children: [ - for (int i = 0; i < rows.length; i++) ...[ - _buildDetailRow(rows[i].key, rows[i].value), - if (i != rows.length - 1) - Divider( - height: 12, - color: ColorConstants.dividerColor, - ), - ], - ], + // Through the kit. The old sheet drew "Product Details" beside a 32pt red + // cancel IconButton (the handle and the barrier already dismiss), and boxed + // the rows in a bordered panel — a container inside a container, on the one + // page family that has none. The rows are a plain ruled list now, and an + // empty payload is one quiet sentence rather than a 40pt-padded red box. + showMilerSheet( + context, + builder: (context) => MilerSheetScaffold( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const MilerSheetHeader( + title: 'Product details', + icon: LucideIcons.package, + ), + SizedBox(height: 14.h), + if (rows.isEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: 16.h), + child: Text( + 'No product details on this booking.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, ), ), - ], - ), + ) + else + for (int i = 0; i < rows.length; i++) ...[ + _buildDetailRow(rows[i].key, rows[i].value), + if (i != rows.length - 1) + Divider(height: 12, color: ColorConstants.borderSubtle), + ], + ], ), - ); - }, + ), + ), ); } diff --git a/lib/views/Dashboard/home/homepage_banner.dart b/lib/views/Dashboard/home/homepage_banner.dart index 4af4c44..972bc5e 100644 --- a/lib/views/Dashboard/home/homepage_banner.dart +++ b/lib/views/Dashboard/home/homepage_banner.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/widget/Bottom_page.dart'; import 'package:miler/Models/stop_status.dart'; @@ -29,6 +31,15 @@ import 'package:miler/views/helpers/constants/design_constants.dart'; /// So the banner lifts itself clear by [BottomPage.bottomInset]. Both call sites /// just pin it to `bottom: 0` and let it place itself. class ActivePickupBanner extends StatefulWidget { + /// The tallest this banner gets, excluding the device inset. + /// + /// Declared here because the surface that overlays a list is the only thing + /// that knows how much of the list it covers. The Deliveries queue reserved + /// a flat `80.h` for it — a number measured once, by eye, that every later + /// change to this widget made wrong silently by hiding the last row of the + /// run behind it. + static double get maxHeight => ButtonSizes.primary + 46.h; + final List> activePickups; final void Function(Map) onTap; @@ -166,11 +177,7 @@ class _Card extends StatelessWidget { color: ColorConstants.pureSurface.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), - child: Icon( - Icons.two_wheeler_rounded, - color: Colors.white, - size: 22.sp, - ), + child: Icon(LucideIcons.bike, color: Colors.white, size: 22.sp), ), SizedBox(width: 11.w), Expanded( @@ -196,7 +203,7 @@ class _Card extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: 15.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, ), @@ -208,7 +215,7 @@ class _Card extends StatelessWidget { Row( children: [ Icon( - Icons.place_rounded, + LucideIcons.mapPin, color: Colors.white.withValues(alpha: 0.72), size: 13.sp, ), @@ -244,7 +251,7 @@ class _Card extends StatelessWidget { shape: BoxShape.circle, ), child: Icon( - Icons.arrow_forward_rounded, + LucideIcons.arrowRight, color: Colors.white, size: 16.sp, ), @@ -312,11 +319,11 @@ class _LiveChip extends StatelessWidget { ), SizedBox(width: 5.w), Text( - 'LIVE', + 'Active', style: TextStyle( color: Colors.white, fontSize: 9.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.7, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/home/pickup_preview_sheet.dart b/lib/views/Dashboard/home/pickup_preview_sheet.dart new file mode 100644 index 0000000..97fc8c0 --- /dev/null +++ b/lib/views/Dashboard/home/pickup_preview_sheet.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/miler_map.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PICKUP PREVIEW — where am I going, and what am I collecting there +/// +/// ── Why this exists ── +/// +/// `Navigate to pickup` used to hand straight off to Google Maps. That is one +/// tap too few. Handing off means the rider **leaves Miler** — his route, his +/// bag counts and his manifest all go behind another app — and he has to make +/// that commitment on the strength of a kitchen's name and a straight-line +/// distance. If the hub has routed him somewhere he does not recognise, he finds +/// out inside a different app, already navigating. +/// +/// So the button opens a preview first: the leg he is about to ride, drawn from +/// where he is standing to where he is going, with the load he will pick up when +/// he arrives. He confirms, and *then* Miler hands off. +/// +/// It is the same surface the card's own tap opens, because they answer the same +/// question — "what is this place?" — and a screen that answered it two +/// different ways depending on which part of a card you touched would be worse +/// than one that answers it once. +/// +/// ── What is on it ── +/// +/// ``` +/// ┌───────────────────────────────────────┐ +/// │ ● ─────────── ▼ │ ← the leg: him, the kitchen +/// └───────────────────────────────────────┘ +/// Vidhya Kitchen +/// ➤ 3.1 km ◷ ~9 min +/// +/// 5 orders · 5 bags +/// ○ Joe Mathew Bag 1 +/// ○ Arun Prakash Bag 2 +/// … +/// +/// [ ➤ Start navigation ] [ 📞 ] +/// ``` +/// +/// The manifest is read straight off [BagManifest] — the same source as the +/// card, the confirmation sheet and the row, so the number he checks against the +/// shelf cannot differ between the screen he decided on and the screen he +/// confirms on. **One order is one bag**, here as everywhere. +/// ───────────────────────────────────────────────────────────────────────── +class PickupPreviewSheet extends StatelessWidget { + /// The place, as the card knows it. + final String placeName; + + /// Every order the rider collects here, in route order. + final List lines; + + /// Straight-line metres from the rider to the place. Null when unknown — + /// nothing is invented, exactly as on the card. + final double? meters; + + /// Ride time to the place. Zero renders nothing. + final Duration ride; + + /// Where the rider is. Null draws the destination alone rather than a leg + /// starting from a guess. + final double? riderLat; + final double? riderLng; + + /// Hands off to the phone's navigation app. Null hides the action — a stop + /// with no coordinates has nothing to navigate to. + final VoidCallback? onNavigate; + + /// Rings the place. Null hides the action. + final VoidCallback? onCall; + + const PickupPreviewSheet({ + super.key, + required this.placeName, + required this.lines, + required this.meters, + required this.ride, + this.riderLat, + this.riderLng, + this.onNavigate, + this.onCall, + }); + + /// Opens the sheet. Kept here so callers do not each re-declare the shape. + static Future show( + BuildContext context, { + required String placeName, + required List lines, + required double? meters, + required Duration ride, + double? riderLat, + double? riderLng, + VoidCallback? onNavigate, + VoidCallback? onCall, + }) { + return showMilerSheet( + context, + large: true, + builder: (_) => PickupPreviewSheet( + placeName: placeName, + lines: lines, + meters: meters, + ride: ride, + riderLat: riderLat, + riderLng: riderLng, + onNavigate: onNavigate, + onCall: onCall, + ), + ); + } + + /// The place's own coordinates, taken from the first order collected there — + /// they all share one counter, so they all share one pin. + ({double lat, double lng})? get _place { + if (lines.isEmpty) return null; + final stop = lines.first.stop; + final lat = _d(stop['pickuplat'] ?? stop['PickupLat']); + final lng = _d(stop['pickuplon'] ?? stop['PickupLon']); + return (lat == 0 || lng == 0) ? null : (lat: lat, lng: lng); + } + + static double _d(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString().trim()) ?? 0; + } + + @override + Widget build(BuildContext context) { + final place = _place; + + // The kit owns fabric, handle and insets; this sheet owns only content. + // It was one of the two opaque holdouts — see the sheet kit's audit. + return MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (place != null) ...[_map(place), SizedBox(height: 16.h)], + Text( + placeName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.pageTitle, + ), + SizedBox(height: 8.h), + _journey(), + SizedBox(height: 18.h), + _manifest(), + SizedBox(height: 18.h), + _actions(), + ], + ), + ); + } + + /// The leg, drawn once. Him, the counter, and the line between them. + /// + /// Deliberately not interactive and deliberately not a navigation surface: + /// this is an *orientation* aid answering "which way is that, and is it far". + /// Turn-by-turn is the phone's job and the button below hands off to it. + Widget _map(({double lat, double lng}) place) { + final placePos = LatLng(place.lat, place.lng); + final hasRider = + riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0; + final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null; + + return ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: SizedBox( + height: 190.h, + // Held back until the sheet has finished rising: a platform map view + // costs the UI thread a couple of hundred milliseconds to build, and in + // the same frame as the entrance it swallows the animation whole. See + // [AfterEntrance]. + child: AfterEntrance( + placeholder: MapPlaceholder(radius: 16.r), + // The real road, both ends pinned, camera fitted to the pair — the + // straight solid stroke this drew was geometry no road follows, and + // a fixed zoom hid the half of the journey the rider is standing + // in. See [MilerLegMap]. + builder: (context) => MilerLegMap( + to: placePos, + from: riderPos, + accent: ColorConstants.primary, + icon: StopKind.pickup.icon, + ), + ), + ), + ); + } + + /// `➤ 3.1 km ◷ ~9 min` — the same pair, the same glyphs and the same rule + /// as the card this opened from. Absent halves are absent, never guessed. + Widget _journey() { + final distance = (meters != null && meters! > 0) + ? RouteMetricsHelper.formatDistance(meters) + : null; + final time = ride > Duration.zero + ? '~${RouteMetricsHelper.formatDuration(ride)}' + : null; + if (distance == null && time == null) return const SizedBox.shrink(); + + // Flexible, because rigid text in a Row is a right-edge overflow waiting + // for a large text scale — at 2.0x this pair ran 72px past the sheet. + return Row( + children: [ + if (distance != null) ...[ + Icon( + LucideIcons.navigation, + size: 16.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + distance, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.figure(17, color: ColorConstants.slateText), + ), + ), + ], + if (distance != null && time != null) SizedBox(width: 18.w), + if (time != null) ...[ + Icon( + LucideIcons.clock, + size: 16.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + time, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.label.copyWith( + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + ), + ), + ), + ], + ], + ); + } + + /// What he is collecting, and which bag each order travels in. + /// + /// Capped and scrollable rather than unbounded: a counter can hand over + /// fifteen orders, and a sheet that grows with the manifest pushes its own + /// actions off the bottom of the screen — the one thing this surface exists to + /// offer. + Widget _manifest() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + BagManifest.countLabel(lines.length), + style: MilerType.body.copyWith( + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + ), + ), + SizedBox(height: 10.h), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: 190.h), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final line in lines) + Padding( + padding: EdgeInsets.symmetric(vertical: 7.h), + child: Row( + children: [ + Container( + width: 7.w, + height: 7.w, + decoration: BoxDecoration( + color: ColorConstants.borderStrong, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Text( + line.customer.isEmpty ? 'Order' : line.customer, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 15.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.onSurfaceVariant, + ), + ), + ), + SizedBox(width: 10.w), + // Ranked under the name, exactly as on the route + // timeline: w700 slate against a w500 mid-tone name + // made a column of shelf references the first thing + // read on a manifest of people. + Container( + padding: EdgeInsets.symmetric( + horizontal: 7.w, + vertical: 3.h, + ), + decoration: BoxDecoration( + color: MilerSurface.canvas, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Text( + line.bag, + maxLines: 1, + style: MilerType.label.copyWith( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } + + /// One primary action and one secondary, in the thumb zone. + /// + /// "Start navigation" rather than "Navigate to pickup": by this point the + /// rider is looking at the leg, so the label's job is no longer to say where — + /// it is to say that pressing it *leaves Miler*. + Widget _actions() { + return Row( + children: [ + if (onNavigate != null) + Expanded( + child: MilerButton( + label: 'Start navigation', + icon: LucideIcons.navigation, + height: ButtonSizes.primary, + onPressed: onNavigate, + ), + ), + if (onNavigate != null && onCall != null) SizedBox(width: 10.w), + if (onCall != null) + MilerIconButton( + icon: LucideIcons.phone, + color: ColorConstants.acceptGreen, + size: ButtonSizes.primary, + semanticLabel: 'Call this pickup location', + tonal: true, + onPressed: onCall, + ), + ], + ); + } +} diff --git a/lib/views/Dashboard/home/route_brief.dart b/lib/views/Dashboard/home/route_brief.dart index ad081c0..2e56461 100644 --- a/lib/views/Dashboard/home/route_brief.dart +++ b/lib/views/Dashboard/home/route_brief.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; @@ -355,18 +356,14 @@ class ShiftBanner extends StatelessWidget { ), child: Row( children: [ - Icon( - Icons.access_time_rounded, - color: ColorConstants.primary, - size: 15.sp, - ), + Icon(LucideIcons.clock, color: ColorConstants.primary, size: 15.sp), SizedBox(width: 7.w), Text( brief.shiftWindowLabel, style: TextStyle( color: ColorConstants.slateText, fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -381,7 +378,7 @@ class ShiftBanner extends StatelessWidget { ? ColorConstants.warning : ColorConstants.secondaryText), fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/views/Dashboard/home/route_timeline.dart b/lib/views/Dashboard/home/route_timeline.dart new file mode 100644 index 0000000..8ead8a1 --- /dev/null +++ b/lib/views/Dashboard/home/route_timeline.dart @@ -0,0 +1,3163 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/stop_area.dart'; +import 'package:miler/views/Dashboard/home/stop_card.dart' show LiveMark; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE ROUTE IS THE INTERFACE +/// +/// Home used to draw a stop as a card: a pane of glass carrying a type mark, a +/// state chip, a 21sp headline, three lines of address, a facts row, a bag chip +/// and a button. **138pt per order.** A kitchen with five orders came to ~690pt +/// of card, and the viewport under the app bar and the trip tabs is about 600 — +/// so a rider working one counter could not see one counter. Two kitchens was a +/// screen and a half of scrolling to answer "which one am I on?". +/// +/// The cards were not too *styled*; they were the wrong shape. A rider does not +/// choose between five orders from Vidhya Kitchen — he rides to Vidhya Kitchen +/// once and collects five bags. The operational unit is the **place**, and the +/// orders are what he counts when he gets there. So the place is a node and the +/// orders are milestones under it: +/// +/// ``` +/// ① Vidhya Kitchen Accepted 5.2 km +/// │ 5 orders · 5 bags ~15 min +/// │ [ ➤ Navigate to pickup ] +/// ├── ○ Joe Mathew Bag 1 +/// ├── ○ Arun Prakash Bag 2 +/// ├── ○ Ravi Kumar Bag 3 +/// └── ○ Kumar S Bag 5 +/// +/// ② Joe Kitchen 1.8 km +/// 3 orders · 3 bags ~5 min +/// ``` +/// +/// ── The header is two columns, not four lines ── +/// +/// It was four stacked left-aligned rows — name, load, distance/ETA, action — +/// which is why the distance read as metadata however it was styled: to find +/// "how far", the eye had to walk down the block and parse each line. Splitting +/// it puts **identity on the left and journey on the right**, on one baseline, +/// so both are answered in a single fixation; it costs two rows rather than +/// four; and down a multi-kitchen run the distances stack into a scan column, +/// which is how "which one is nearest" gets answered without reading. +/// +/// A collapsed group is **~58pt** — the height of a section heading. Expanded +/// with five orders it is ~300. The same information, a third of the page. +/// +/// ── Why this is not a package ── +/// +/// Re-checked against the current releases rather than inherited: `timeline_tile` +/// is the most-liked option on pub.dev (1.9k) and was **last published five +/// years ago**. `timelines_plus` is the maintained fork — 2.0.1, eight days old, +/// 160 pub points, 277 likes — and it is a good package. It still loses here, +/// for a reason that is about this screen rather than about the package: its +/// node is styled through `indicatorStyleBuilder` / `connectorStyleBuilder`, +/// which are **style-per-index and carry no per-node interaction or selection +/// state**. Every one of Miler's nodes is a state machine — pending → accepted +/// → arrived → collected, crossed with a batch tick that drives [SelectionBar] — +/// so the builders would have received a state they cannot express and the row +/// content, the tap targets and the selection would still have been written +/// here. What the package would actually have supplied is [_Spine]: a line, a +/// dot, and the arithmetic that keeps them aligned, which is the sixty lines +/// below. +/// +/// `glass_kit` was evaluated for the group surface and rejected for a stronger +/// reason: the app has already decided against `BackdropFilter` in scrolling +/// lists — a real blur re-samples everything behind it every frame, and twenty +/// of them is the most reliable way to drop a mid-range Android to 30fps (see +/// [ColorConstants.glassCard]). The blur is reserved for the two surfaces that +/// genuinely sit over live content. A glassmorphism package would have brought +/// back the thing that decision removed. +/// +/// **No dependency was added.** The timeline is Flutter primitives on the +/// existing tokens. +/// ───────────────────────────────────────────────────────────────────────── + +/// One pickup place and every order the rider collects there. +/// +/// Built by the page from the trip's own stops — this holds no state of its +/// own and derives no counts. The bag lines come from [BagManifest.forGroup], +/// which is the single source for "how many bags" and "which bag": the count is +/// `orders.length` because a bag is what an order arrives in, and the identity +/// is the printed label or the order's position **in this group**. The timeline +/// must never number bags across the route. +@immutable +class RouteGroup { + /// Stable key — the source id where the hub sends one, the name otherwise. + final String key; + + /// What the rider reads on a shop sign. Empty on a route with no sources, in + /// which case the group is drawn flat (see [RouteTimeline]). + final String name; + + /// The orders still shown on Home, in route order. + final List> stops; + + /// **Every** order the hub assigned from this place, collected ones + /// included — which is what the bag numbers are counted against. + /// + /// Load-bearing, and the reason this is a second list rather than a getter. + /// Bag identity is the order's position *in its own pickup group*, so once + /// the rider has collected Bag 1 and it leaves Home, numbering the four that + /// remain from the visible list would renumber Bag 2 as Bag 1 — and he would + /// be reading a different number off the phone from the one printed on the + /// shelf. See [BagManifest]. + final List> allStops; + + /// The trip index of the first stop, for travel time and for numbering. + final int firstIndex; + + /// The rung every choosable order in this group is on. + final StopState state; + + /// Each of [stops]' own state, in the same order. + /// + /// A group's rung is the rung it is *worked* on, and a group can hold orders + /// that are not on it — one accepted and one still pending off the same + /// counter. The selection control has to gather one rung only: a set spanning + /// two makes the bar fall back to Accept, which would be a control that ticks + /// two orders and then offers a verb that is wrong for one of them. + final List stopStates; + + /// Travel time to the group's first stop. Zero when unknown — never invented. + final Duration travel; + + /// How far the rider is from this place, in metres. + /// + /// Null when nothing usable exists — no live fix, no coordinates on the + /// booking, no `kms` from the hub. **Never a zero standing in for unknown**: + /// `0.0` would render as "0 m", which is a confident statement that the rider + /// is standing at the counter. See [RouteMetricsHelper.metersToStop]. + final double? meters; + + /// How many of this place's orders are on [state], and how many there are in + /// all — the two halves of `Accepted (1 of 4)`. + /// + /// The denominator is [allStops], not [stops]: an order that has been + /// collected leaves the visible list but did not leave the counter, and a + /// rider reading "1 of 3" off a place he knows holds four would not trust the + /// number again. + int get onRungCount => stopStates.where((s) => s == state).length; + + int get groupSize => allStops.isNotEmpty ? allStops.length : stops.length; + + /// True when the header **is** the stop: one order, at one address, with no + /// place above it worth naming. + /// + /// A parcel route is always this — the rider rides to each customer for one + /// consignment, so there is no counter to group under and a header followed + /// by a single child would print the same name twice. A meal order whose + /// payload names no kitchen lands here too, for the same reason. + final bool flat; + + const RouteGroup({ + required this.key, + required this.name, + required this.stops, + required this.allStops, + required this.firstIndex, + required this.state, + required this.stopStates, + this.travel = Duration.zero, + this.meters, + this.flat = false, + }); + + // ── The journey, as two operational facts ──────────────────────────────── + // + // "How far is it and how long will it take" is the question a rider asks + // between finishing one counter and setting off for the next, and until now + // Home could only answer half of it — a leg duration chained from the stop + // before, with no distance anywhere on the route. + // + // Both halves come from one measurement so they cannot contradict each other: + // the metres to the place, and the time that distance takes at the rider's + // usable speed. Where there are no metres the leg estimate still answers the + // time half alone. Where there is neither, **nothing is printed** — an + // invented "~5 min" on a screen a rider plans around is worse than a gap. + + /// Ride time to this place. Derived from [meters] when there is a measured + /// distance, and from the route's own leg estimate otherwise. + Duration get rideTime => (meters != null && meters! > 0) + ? RouteMetricsHelper.travelTime(meters) + : travel; + + /// `2.4 km · ~6 min`, or whichever half is known, or empty. + /// + /// The `~` is not decoration: this is an estimate from a straight-line + /// distance and an average speed, and the tilde is what stops the rider + /// reading it as a promise the app cannot keep. + String get journeyLabel { + final ride = rideTime; + return [ + if (meters != null && meters! > 0) + RouteMetricsHelper.formatDistance(meters), + if (ride > Duration.zero) '~${RouteMetricsHelper.formatDuration(ride)}', + ].join(' · '); + } + + /// One line per order still on Home — numbered against the whole group. + /// + /// The manifest is built from [allStops] and then filtered, never built from + /// [stops]: the count and the identity both come from the pickup group the + /// rider was handed, not from whatever is left of it. + List get manifest { + final shown = {for (final s in stops) (s['orderid'] ?? '').toString()}; + return [ + for (final line in BagManifest.forGroup(allStops)) + if (shown.contains(line.orderId)) line, + ]; + } + + /// `5 orders · 5 bags`. The two halves of the rule, side by side, so the + /// rider can check the shelf against the phone without doing arithmetic. + String get countLabel => BagManifest.countLabel(stops.length); + + /// Orders off this counter the rider has not taken on yet. + int get pendingCount => + stopStates.where((s) => s == StopState.pending).length; + + /// One order's own state. Falls back to the group's rung for an id the group + /// does not hold, which cannot happen but must not throw if it does. + StopState stateOf(String orderId) { + for (final (i, s) in stops.indexed) { + if ((s['orderid'] ?? '').toString() == orderId) return stopStates[i]; + } + return state; + } + + /// True when every order from this place is in the rider's hands. The group + /// stays on the run as a settled line so the sequence still reads — the work + /// itself has moved to the work tab. + bool get isSettled => stops.isEmpty; + + bool get isDone => state == StopState.collected || state == StopState.done; + + /// The rider is on this stop right now. + bool get isLive => state == StopState.active; +} + +/// The whole run: a head, then one expandable group per pickup place. +/// +/// Stateful for exactly one thing — which groups are open. That is a view +/// preference, not operational state, so it deliberately does **not** go into +/// `_HomepageState`: the page owns accept / reject / select / collect and this +/// owns nothing the hub or the stores need to know about. There is one state +/// machine on Home and this is not a second one. +class RouteTimeline extends StatefulWidget { + final List groups; + + /// Ticked order ids. Owned by the page; the timeline only reads them. + final Set selectedIds; + + /// Order ids with an action in flight. + final Set busyIds; + + /// Replaces the whole selection — the group's one selection control. + final ValueChanged>? onSelectAll; + + /// Toggles one order's tick. + final ValueChanged? onToggleSelect; + + /// Opens one order's detail sheet. Progressive disclosure for the address, + /// the phone and everything else a step deliberately does not carry. + final ValueChanged>? onStopTap; + + /// Opens navigation for a whole group. **One per group, never per order** — + /// five Navigate buttons that go to the same counter was the single largest + /// source of clutter on the old screen. + final ValueChanged>? onNavigateGroup; + + /// Rings the pickup place. Same rule as navigation: it belongs to the + /// *location*, so it is one control on the card and never one per order. + final ValueChanged>? onCallGroup; + + /// Opens the pickup preview — the leg to this place and the load waiting on + /// it. See [PickupPreviewSheet] for why navigation goes through a preview + /// rather than straight out to the phone's map app. + final ValueChanged? onPreviewGroup; + + /// Takes the rider back into the stop he is already working — the way in + /// that used to be a red banner floating over the foot of the page. + final ValueChanged>? onContinue; + + /// Returns a declined order to undecided, so it can be ticked again. + /// + /// A rejected stop is on Home precisely because the rider might change his + /// mind, which is the whole reason it stays visible rather than vanishing. + /// Deliberately not "accept it now": accepting is one deliberate press on the + /// floating bar. + final ValueChanged>? onUnreject; + + /// The whole route on a map, from the head. + final VoidCallback? onViewRoute; + + const RouteTimeline({ + super.key, + required this.groups, + this.selectedIds = const {}, + this.busyIds = const {}, + this.onSelectAll, + this.onToggleSelect, + this.onStopTap, + this.onNavigateGroup, + this.onCallGroup, + this.onPreviewGroup, + this.onContinue, + this.onUnreject, + this.onViewRoute, + }); + + @override + State createState() => _RouteTimelineState(); +} + +class _RouteTimelineState extends State { + /// ── One place open at a time ── + /// + /// This was a `Map` of per-group overrides, so every card the + /// rider opened stayed open: three kitchens expanded is ~600pt of orders, the + /// route stops being a route, and the question the screen exists to answer — + /// *which counter am I on* — is buried under two he is not. + /// + /// An accordion is the honest model, because the underlying fact is an + /// accordion: he is at one counter. Opening a place closes the last one, and + /// the run stays readable however many kitchens the hub assigns. + /// + /// Null means "nothing chosen by hand yet" and defers to [_defaultOpenKey], + /// so the group he is working is open when the screen loads without the rider + /// having touched anything. Once he does touch one, his choice holds — an + /// accordion that keeps reasserting its own default is a control that fights + /// back. + String? _openKey; + bool _touched = false; + + /// The group the rider is working, opened for him. + /// + /// Ordered by *rung*, not by route position: a counter he has already reached + /// outranks one he has only accepted, because a rider standing at a shelf is + /// mid-handover and everything else can wait. Opening every group by default + /// would put him back on a screen he has to scroll. + String? get _defaultOpenKey { + for (final rung in const [ + StopState.active, + StopState.accepted, + StopState.pending, + ]) { + for (final g in widget.groups) { + if (g.state == rung) return g.key; + } + } + return widget.groups.isEmpty ? null : widget.groups.first.key; + } + + bool _isOpen(RouteGroup g) => + g.key == (_touched ? _openKey : _defaultOpenKey); + + /// Opens [g] and closes whatever was open; tapping the open one shuts it. + /// + /// **The read happens before the write.** `_touched = true` first, and then + /// `_isOpen(g)` answers against the *new* mode — where `_openKey` is still + /// null — so an open card reported itself shut and the toggle re-opened it. + /// The chevron looked completely dead, and the cause was one statement of + /// ordering inside a two-line setState. + void _toggle(RouteGroup g) { + final wasOpen = _isOpen(g); + setState(() { + _touched = true; + _openKey = wasOpen ? null : g.key; + }); + } + + @override + Widget build(BuildContext context) { + final open = _defaultOpenKey; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _head(), + for (final (i, g) in widget.groups.indexed) + PickupTimelineGroup( + group: g, + ordinal: i + 1, + expanded: _isOpen(g), + isCurrent: g.key == open, + last: i == widget.groups.length - 1, + selectionMode: widget.selectedIds.isNotEmpty, + selectedIds: widget.selectedIds, + busyIds: widget.busyIds, + onToggle: () => _toggle(g), + onSelectAll: widget.onSelectAll, + onToggleSelect: widget.onToggleSelect, + onStopTap: widget.onStopTap, + onNavigate: widget.onNavigateGroup, + onCall: widget.onCallGroup, + onPreview: widget.onPreviewGroup, + onContinue: widget.onContinue, + onUnreject: widget.onUnreject, + ), + ], + ); + } + + /// Every order on the run the rider may tick right now, across the flat + /// groups — a parcel route, where each stop is its own place. + /// + /// One rung only, and only when there are at least two: with one stop its own + /// row already is the select-all. + List get _flatSelectIds { + if (!widget.groups.every((g) => g.flat)) return const []; + final ids = []; + for (final g in widget.groups) { + if (g.state != StopState.pending) continue; + for (final (i, s) in g.stops.indexed) { + if (g.stopStates[i] != StopState.pending) continue; + final id = stopIdOf(s); + if (id.isNotEmpty) ids.add(id); + } + } + return ids.length < 2 ? const [] : ids; + } + + /// `TODAY'S RUN ─────────────── MAP ›` + /// + /// What is left of the old `START · KITCHEN` hub row. The row itself was a + /// node, a label and a 36pt band for a fact the rider knows — his day starts + /// where his day starts — and its one piece of content worth keeping was the + /// map action. + Widget _head() { + final selectIds = _flatSelectIds; + final picked = selectIds.where(widget.selectedIds.contains).toList(); + final all = selectIds.isNotEmpty && picked.length == selectIds.length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Title left, action right, and nothing between them ── + // + // A rule ran across the gap, and it was the thing making the row look + // off: it is a 1pt line whose *optical* centre sits above the cap-height + // of the eyebrow beside it and below the middle of the pill on the + // other side, so three elements on one row settled on three different + // centre lines. It was also doing no work — the section is already + // separated from the strip above it by whitespace and from the route + // below it by a card edge. + // + // Title and action, pushed apart, both vertically centred on the row. + // The action's right edge lands on the same margin as the cards below, + // because the head and the cards share one padded column. + Padding( + padding: EdgeInsets.only(bottom: 10.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + // `spaceBetween`, not a `Spacer`. A Spacer is an `Expanded` with + // flex 1, and the eyebrow beside it is a `Flexible` with flex 1 — + // so the two split the free width evenly, the eyebrow used almost + // none of its half, and the leftover fell *after* the pill. The Map + // action sat ~37pt short of the card edges below it, which is + // exactly the misalignment that reads as sloppiness without anyone + // being able to name it. + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Flexible, not a bare `Text`: at 320pt and 2.0× text scale the + // eyebrow and the pill together are wider than the phone, and a + // row of two natural-width children has no give at all. Caught by + // `trip_brief_layout_test.dart`, which sweeps four widths against + // four text scales for exactly this shape of bug. + Flexible( + child: Text( + 'TODAY’S RUN', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.eyebrow, + ), + ), + if (widget.onViewRoute != null) ...[ + SizedBox(width: 10.w), + // Sentence case against the eyebrow's caps. The two sit on one + // line and they are not the same kind of thing — one names the + // section, one does something — so they are set differently + // rather than both shouting. + _TextAction( + label: 'Map', + icon: LucideIcons.map, + onTap: widget.onViewRoute!, + ), + ], + ], + ), + ), + // ── "Take the lot", at the head of what it takes ── + // + // A run of single-order stops — a parcel route — has no counter to + // gather, so the select-all belongs to the run rather than to any one + // node. It sat at the *foot* of the route once: on a six-stop trip that + // is a screen of scrolling from the first stop, and it is the control a + // rider reaches for **before** reading the list rather than after. + // Selection mode only — see the note in [PickupTimelineGroup._body]. + if (selectIds.isNotEmpty && + widget.onSelectAll != null && + widget.selectedIds.isNotEmpty) + Padding( + padding: EdgeInsets.only(bottom: 4.h), + child: SelectAllAction( + total: selectIds.length, + picked: picked.length, + noun: 'stop', + onTap: () => widget.onSelectAll!(all ? const [] : selectIds), + ), + ), + ], + ); + } +} + +/// `Select all · 5 orders` / `3 of 5 selected · Clear` +/// +/// ── Why this is not a button any more ── +/// +/// It has been two things, and both were the same mistake at different sizes: a +/// tinted full-width strip with a checkbox on the run, and a tonal +/// `Select all 5 orders` button in the kitchen group. Either way it was **a +/// filled control roughly the size of the journey action**, sitting one line +/// above it, for a helper that only ever leads to that action. A rider scanning +/// the group saw two slabs and had to read both to find out which one was the +/// thing to do. +/// +/// So it loses its surface. What is left is a compact 40pt text action — a +/// small state glyph, the words, and nothing behind them — which is the weight +/// a *means to an action* should carry next to the action itself. It costs +/// ~14pt where the button cost 60, and the tap target is the whole row. +/// +/// The wording carries the state rather than a second control doing it: at rest +/// it offers, part-way through it counts, and full it clears. A separate +/// `ALL`/`CLEAR` affordance on the right was one more thing to aim at for a +/// gesture the row already performs. +class SelectAllAction extends StatelessWidget { + final int total; + final int picked; + + /// `order` on a kitchen group, `stop` on a parcel run — the same control + /// naming whatever it is gathering. + final String noun; + + /// True inside a place's own panel, where the card above already states the + /// count and the scope. False at the run head, which has neither. + final bool scoped; + + final VoidCallback onTap; + + const SelectAllAction({ + super.key, + required this.total, + required this.picked, + required this.onTap, + this.noun = 'order', + this.scoped = false, + }); + + /// The state glyph. It changes **shape**, not only colour — an empty box, a + /// half-filled one, a tick — so sunlight and a grayscale screen both survive + /// it where a red-vs-grey tick survives neither. + Widget _glyph(bool all, bool some, Color accent) => AnimatedSwitcher( + duration: DesignConstants.motionState, + child: Icon( + all + ? LucideIcons.circleCheck + : some + ? LucideIcons.circleMinus + : LucideIcons.circleCheck, + key: ValueKey(all ? 'all' : (some ? 'some' : 'none')), + size: 18.sp, + color: accent, + ), + ); + + @override + Widget build(BuildContext context) { + final all = picked == total; + final some = picked > 0 && !all; + final live = some || all; + + final plural = total == 1 ? noun : '${noun}s'; + // ── The count is dropped from the offer, kept in the answer ── + // + // `Select all · 5 orders` shares a row with the journey action inside a + // kitchen's panel, and the pair does not fit — it ellipsised to + // `Select all · 5 …`, which is a control that has lost the number it was + // there to state. + // + // The number was already redundant *there*: the card says `5 orders · 5 + // bags` twenty points above, and the control sits inside the counter it + // applies to, so its scope cannot be misread. The run-level version, which + // has no card above it to state scope, keeps the count. + // + // Once he has ticked something the counts come back, because at that point + // they are the answer rather than the offer: how many, out of how many. + // ── The three states of the offer, and each states its number ── + // + // Resting said a bare `Select all` inside a kitchen, on the argument that + // the card two lines above already said `5 orders`. The card is gone, and + // the argument was thin even then: the count is what makes the offer an + // offer — *five* is the thing the rider is agreeing to, and a control that + // makes him look elsewhere for its own object is a control he reads twice. + final label = picked == 0 + ? 'Select all $total' + : all + ? 'All $total selected · Clear' + : '$picked of $total selected'; + + final Color accent = live + ? ColorConstants.primary + : ColorConstants.secondaryText; + + return Semantics( + button: true, + selected: all, + label: all + ? 'Clear the selection' + : 'Select all $total $plural at this stop', + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Container( + // Above the 44 floor without a fill to make it look like a button. + constraints: BoxConstraints(minHeight: 44.h), + // 10, not 12. The order rows inset their content 12 from *their* + // rail, but the rail this row sits in is 2 wider — the `+2` on + // [PickupTimelineGroup._railWidth] — so matching the number instead + // of the axis left a 2pt drift. The axis is the thing being matched; + // `home_grid_test.dart` measures it so the two cannot part again. + padding: EdgeInsets.only(left: scoped ? 10.w : 0), + child: Row( + // ── Inside a panel it closes the row; at the run head it opens it ── + // + // Scoped, this control heads a list of orders that are themselves + // right-aligned to the panel edge, and it sat alone on the left + // with the whole width of the panel of empty space after it. A + // control with nothing beside it should sit on an edge, and the + // right one is where a list's own controls live. + // + // Unscoped — the run head — it is the first thing on the screen + // above the route and keeps the reading edge. + // ── Both versions read from the left ── + // + // The scoped one was pushed to the right edge, on the argument + // that a control with nothing beside it should sit on an edge and + // that a list's own controls live on the right. That was an + // argument about a *panel* — it had a right edge to sit on, and + // the orders were inside it. + // + // On the open ground there is no panel, and the right edge now + // belongs to the bag column. A select-all floating out there reads + // as a fourth thing in the Bag axis. It heads the list it selects, + // so it starts where the list starts: the same left axis the + // customer names below it keep. + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // ── One rail, not two ── + // + // Measured, not guessed: the scoped row sits inside `_railed`, + // which already spends the rail width on it — so reserving a + // second rail *here* put the label 36pt right of the customer + // names below it. Two corrections for one indent, and the row + // that heads the list ended up further in than the list. + // + // The mark is inline and the caller owns the indent, which is + // the same arrangement every other railed row on this screen + // uses. `home_grid_test.dart` measures the axis so the pair + // cannot drift apart again. + // The mark is drawn by the rail on a scoped row — see + // [PickupTimelineGroup._selectAllMark] — so only the run-head + // version, which has no rail, carries it inline. + if (!scoped) ...[_glyph(all, some, accent), SizedBox(width: 8.w)], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: MilerType.label.copyWith( + fontWeight: FontWeight.w700, + color: accent, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// One pickup place: a header that is always a header, and the orders under it. +class PickupTimelineGroup extends StatelessWidget { + final RouteGroup group; + + /// 1-based position in the run — the `01` on the header. + final int ordinal; + + final bool expanded; + + /// The group the rider is working. Only this one carries an action. + final bool isCurrent; + + /// Suppresses the connector running out of the bottom of the last group. + final bool last; + + /// True while *any* order on the run is ticked. + /// + /// Deliberately the run's state and not this group's: a rider who has ticked + /// three orders at one counter and scrolls to the next expects the same + /// controls there, and a select-all that appeared per-group would leave him + /// looking at a group that offers nothing while he is plainly mid-selection. + final bool selectionMode; + + final Set selectedIds; + final Set busyIds; + final VoidCallback onToggle; + final ValueChanged>? onSelectAll; + final ValueChanged? onToggleSelect; + final ValueChanged>? onStopTap; + final ValueChanged>? onNavigate; + final ValueChanged>? onCall; + final ValueChanged? onPreview; + final ValueChanged>? onContinue; + final ValueChanged>? onUnreject; + + const PickupTimelineGroup({ + super.key, + required this.group, + required this.ordinal, + required this.expanded, + required this.isCurrent, + required this.last, + this.selectionMode = false, + this.selectedIds = const {}, + this.busyIds = const {}, + required this.onToggle, + this.onSelectAll, + this.onToggleSelect, + this.onStopTap, + this.onNavigate, + this.onCall, + this.onPreview, + this.onContinue, + this.onUnreject, + }); + + /// Orders in this group the rider may tick right now. + /// + /// A selection that spans two rungs makes the bar fall back to Accept, so the + /// control only ever gathers the rung the group is on. + List get _choosableIds => [ + for (final (i, s) in group.stops.indexed) + // The stop's **own** rung, not the group's. Gating on the group's would + // sweep a pending order into an accepted counter's selection. + if (stopIdOf(s).isNotEmpty && + group.stopStates[i] == group.state && + _isChoosable(group.stopStates[i])) + stopIdOf(s), + ]; + + static bool _isChoosable(StopState state) => + state == StopState.pending || + (ServiceProfile.active.handsOffAtCollection && + (state == StopState.accepted || state == StopState.active)); + + /// The one group the rider is working, given a ground of its own. + /// + /// ── Why a wash and not a card ── + /// + /// "Which of these is my current mission" was being carried by a chevron + /// rotation and two points of type size, and neither survives a glance at + /// arm's length. The obvious fixes are both wrong for this screen: a card + /// around the active group brings back the border, the shadow and the two + /// gutters this page spent three redesigns removing, and a red left-edge bar + /// spends the brand on a *label* rather than on an action. + /// + /// The expanded orders get a ground of their own. + /// + /// This is what makes open and shut read as two different things rather than + /// as the same card at two heights. It is a tonal field *inside* the card, + /// under the steps only — so the header stays on the card's own white and the + /// orders read as contents rather than as more header. + /// + /// **No horizontal padding.** Insetting it would push the spine right on the + /// open group alone, so numbered nodes would step in and out as the rider + /// expanded one — and one vertical axis for every node is the contract + /// `stop_row_alignment_test.dart` exists to hold. (It bled 8pt each side for + /// one build, via a negative margin. `Container.margin` goes through + /// `Padding`, which asserts non-negative: the whole group rendered as a + /// `RenderErrorBox`.) + bool get _grounded => expanded && !group.flat && !group.isSettled; + + @override + Widget build(BuildContext context) { + final manifest = group.manifest; + final chosen = _choosableIds.where(selectedIds.contains).length; + + // ── The pickup place is a card again, and this time it earns it ── + // + // The route spent three redesigns getting *out* of cards, and the reasoning + // held: a card per **order** is 138pt of frame around one line of content, + // and five of them off one counter did not fit a viewport. + // + // ── And now the place is not a card either ── + // + // A card per *place* was the right correction to a card per *order*, and + // it is still the wrong object. The thing being grouped here is a stop on + // a route, and a route already has a way of showing that two things belong + // together: the spine, the node, the indent under it. Drawing a white + // rounded rectangle around all of that says the same thing a second time + // in a louder voice — and then the orders needed a *tinted* rectangle + // inside it to separate themselves from their own container, which is the + // point at which the furniture is arguing with itself. + // + // So: no surface, no border, no shadow, no radius. The group is the spine + // plus whitespace plus type, and the orders sit directly on Home's own + // daylight ground. What used to be conveyed by an edge is conveyed by the + // node the header hangs off and by the indent the orders keep to. + return Padding( + padding: EdgeInsets.only(bottom: 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: group.flat + ? (onStopTap == null + ? null + : () => onStopTap!(group.allStops.first)) + : onToggle, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_header(context, chosen), _locationActions(context)], + ), + ), + if (!group.flat) + // Height and opacity together, on the app's own state duration. + // A group opening is a state change in place, so it uses the + // token that names that — not a spring, and not a curve + // invented here. + AnimatedCrossFade( + firstChild: const SizedBox(width: double.infinity, height: 0), + secondChild: _body(context, manifest, chosen), + crossFadeState: expanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: DesignConstants.motionState, + sizeCurve: Curves.easeOutCubic, + ), + ], + ), + ); + } + + /// ── Actions on the *place*, on the card, whether or not it is open ── + /// + /// Navigate and Call are both answers to "I am going to this counter", and + /// neither has anything to do with any one order. They used to live inside the + /// expanded body, which meant a rider who wanted to ring a kitchen had to + /// first open a list of five customers he was not looking for — a location + /// action behind an order-level disclosure. + /// + /// So they sit on the card, under the identity, and stay there collapsed. One + /// of each per place, never one per row: five Navigate buttons pointing at the + /// same counter was the single largest source of clutter on the old screen, + /// and five Call buttons dialling the same number would be the same mistake + /// with a different glyph. + /// + /// The pair is weighted: Navigate is the filled brand action and hugs its + /// label; Call is a bordered icon button at the far right, 48pt square, which + /// is discoverable without competing with the destination above it. + Widget _locationActions(BuildContext context) { + final phone = stopPhoneOf(group.stops.isEmpty ? {} : group.stops.first); + final showCall = onCall != null && phone.isNotEmpty && !group.isSettled; + // A flat stop's card tap already opens its detail sheet, so an info button + // beside it would be the same gesture twice. + final showInfo = !group.flat && onPreview != null && !group.isSettled; + + // Under way, with nothing on this screen able to move it on: say where it + // *is* worked. A sentence, not a button — a control that cannot be pressed + // is not a status, and this is the one case with nothing to offer but + // wayfinding. + if (group.isLive && !_isChoosable(group.state) && onContinue == null) { + return Padding( + padding: EdgeInsets.fromLTRB(_railWidth, 0, 14.w, 14.h), + child: Row( + children: [ + Icon(LucideIcons.bike, size: 16.sp, color: ColorConstants.primary), + SizedBox(width: 8.w), + Flexible( + child: Text( + 'In progress — finish it in ' + '${ServiceProfile.active.workTabLabel}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.label.copyWith( + fontWeight: FontWeight.w700, + color: ColorConstants.primary, + ), + ), + ), + ], + ), + ); + } + + // ── A flat stop keeps its journey action on the card ── + // + // The action moved into the dropdown so that only the *open* place shows + // one. A flat stop has no dropdown — the card is the stop — so moving it + // there took `Continue pickup` off a parcel route's live row entirely, + // which is the only way back into a stop the rider is standing in. + // `active_stop_row_test.dart` caught it. + // The journey action belongs to the *place*, so it belongs on the place's + // own action row — for a kitchen as much as for a flat stop. It used to be + // flat-only, with kitchens carrying theirs inside the orders panel; see the + // note on [_railRow] for why that was the wrong container. + final journey = _journeyAction(); + final state = _chip; + + if (journey == null && state == null && !showCall && !showInfo) { + return const SizedBox.shrink(); + } + + // ── Both utilities in the right corner, in a fixed order ── + // + // Call, then details. They are the two things a rider does *about* a place + // without going to it, they are the same size, and they sit in the same + // corner on every card in the run — so reaching either is muscle memory + // rather than a search. + // + // Right-aligned by `MainAxisAlignment.end` rather than by a `Spacer`: with + // nothing flexible beside them the two are equivalent, and `end` cannot be + // broken later by adding a `Flexible` sibling that would silently start + // splitting the free width with it. + // ── The utilities sit on the header's own right margin ── + // + // They were inset 14 while the header content above them uses 6, so the two + // circles hung ~8pt further from the card edge than everything they sit + // under — and a circle reads as further in again, because its silhouette + // curves away from the margin where a straight edge would meet it. The + // result was a ragged right side on the one column of the card that is + // supposed to be a fixed landmark. + // ── One row at reading scale, two at accessibility scale ── + // + // The state word, a labelled button and two 48pt circles cost ~300pt of a + // ~314pt row. That is a good trade at 1.0× and impossible at 2.0×, where it + // overflowed a 320pt phone. Rather than truncate the primary action, the + // state lifts onto its own line above the controls — the same threshold the + // header's journey column uses, and height is the cheaper thing to spend + // once type is already large. + final wide = MediaQuery.textScalerOf(context).scale(14) <= 14 * 1.35; + + // ── Exactly one flexible thing per row ── + // + // The row held three flex children — the state word, a `Spacer` and the + // button — each with flex 1, so they split the free width in thirds. The + // button was handed ~67pt of the ~130 it needs and collapsed to a bare + // icon, while "Accepted" clipped to "Accepte". Three controls fought over + // space the row actually had. + // + // Wide: the button takes its natural width and the **state** absorbs what is + // left, because a state word is the one thing here that can ellipsise + // without losing its job. Stacked: the button is the flexible one, since it + // is alone on its line with nothing to take space from. + final controls = [ + if (journey != null) ...[ + _CircleAction( + icon: group.isLive ? LucideIcons.arrowRight : LucideIcons.navigation, + color: ColorConstants.primary, + filled: true, + label: group.isLive + ? 'Continue this stop' + : 'Navigate to this pickup', + onTap: journey, + ), + SizedBox(width: 8.w), + ], + if (showCall) + _CircleAction( + icon: LucideIcons.phone, + color: ColorConstants.acceptGreen, + label: 'Call this pickup location', + onTap: () => onCall!(group.stops.first), + ), + if (showCall && showInfo) SizedBox(width: 8.w), + if (showInfo) + // ── "i", not "⋯" ── + // + // A three-dot menu promises a *list of choices* and there is one thing + // behind this: where the place is and what is waiting there. An overflow + // glyph that opens a sheet rather than a menu is a small lie the rider + // pays for every time he taps it expecting options. + _CircleAction( + // The storefront, not an "i". This circle answers "about this + // place" — the map to it and the manifest at it — and the + // storefront is already the app's glyph for a pickup place on the + // Activity row and the record's route. An info glyph is the one + // icon that names no subject; the place's own mark does. + icon: LucideIcons.store, + color: ColorConstants.onSurfaceVariant, + label: 'Pickup details and map', + onTap: () => onPreview!(group), + ), + ]; + + return Padding( + // Zero on the right: the utilities finish flush with the card's own edge. + // This is the limit — the next point out is outside the card. + padding: EdgeInsets.fromLTRB(_railWidth, 0, 0, 12.h), + child: wide + ? Row( + children: [ + // ── State left, actions right ── + // + // One row, read left to right as a sentence about the place: + // what it is, then what he can do about it, ordered by weight — + // go there, ring it, read it. Nothing in this row is about which + // bags he has ticked. + Expanded(child: state ?? const SizedBox.shrink()), + ...controls, + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (state != null) ...[state, SizedBox(height: 10.h)], + // `end`, not a `Spacer`: a Spacer is an `Expanded` and would + // split the free width with the button's own `Flexible`, + // squeezing the primary action until it overflowed — 3pt, on a + // 320pt phone at 2.0×. + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: controls, + ), + ], + ), + ); + } + + /// What the card's journey control does — ride there, or get back into the + /// stop he is already inside. Null when the group has neither to offer. + /// + /// ── Why this stopped returning a button ── + /// + /// It was a labelled `MilerButton` sitting beside two 48pt circles, and the + /// four things on that row — state, button, call, details — could not share + /// 314pt: the state clipped to "Accepte" and the button collapsed to a bare + /// icon. Widening one starved another. + /// + /// The fix is not more space, it is fewer languages. The three controls are + /// the three things a rider does *about a place*, so they are one cluster of + /// equal circles — the journey filled in the brand because it is the primary, + /// the other two tonal. That is what the row was reaching for and could not + /// say while one of the three was a different shape. + /// + /// The arrow carries it without a word: it is the navigation glyph, it is the + /// only filled control on the card, and the label survives in full on the + /// preview sheet the "i" opens. + VoidCallback? _journeyAction() { + final bool choosable = _isChoosable(group.state); + + // ── The primary action on an accepted place is the journey ── + // + // This opened the preview sheet instead, on the argument that handing off + // means the rider *leaves Miler* — route, counts and manifest all behind + // another app — so he should see the leg and the load before committing. + // The reasoning is sound and the placement was not: it put the one thing + // the card exists to offer, once the work is his, behind a bottom sheet. He + // has accepted; the decision is made; what is left is to ride there. + // + // The preview is not lost — it is what the "i" beside this opens, which is + // the control that promises a look at a place rather than a move to it. + if (group.state == StopState.accepted && onNavigate != null) { + return () => onNavigate!(group.stops.first); + } + // The stop under way on a parcel route: back into the flow he is inside. + if (group.isLive && !choosable && onContinue != null) { + return () => onContinue!(group.allStops.first); + } + return null; + } + + /// The rail column's width, so everything hanging under the header lines up + /// with the content beside the node rather than with the node itself. + static double get _railWidth => _Spine.width + 2; + + // ── The header ─────────────────────────────────────────────────────────── + // + // Everything needed to understand the stop and nothing else. The customer, + // the address, the phone and the parcel counts that used to be on five cards + // are one tap away in the steps and the sheet; what belongs here is where, + // how many, how far, what state, and — on the one group he is working — what + // to do next. + + /// The load: what is here, and how much of it is not his yet. + /// + /// A settled group has nothing left to hand over, so `0 orders · 0 bags` + /// would be arithmetic about an empty set. It states what happened instead. + String _loadLine(int chosen) { + final rejected = group.flat && group.state == StopState.rejected; + + if (rejected) { + // A declined stop says so where its counts would be: it is not travelling + // in a bag. It stays on the run, struck through, so the rider can see he + // handled it rather than wondering where it went. + return 'Rejected'; + } + if (group.isSettled) { + final n = group.allStops.length; + return '$n ${n == 1 ? 'bag' : 'bags'} collected'; + } + return [ + // ── A flat node is a stop, and a stop has a position ── + // + // `1 order · 1 bag` says nothing about a single-order node, and a parcel + // pickup does not travel in a bag at all. What names it is where it falls + // in the run — the trip's own index, which does **not** renumber as stops + // finish, so a stop is never renamed mid-shift. + if (group.flat) 'Stop ${group.firstIndex + 1}' else group.countLabel, + // How much of this counter is not his yet. A group can hold one accepted + // order and one still pending, and the count above cannot say so alone. + if (!group.flat && + group.pendingCount > 0 && + group.pendingCount < group.stops.length) + '${group.pendingCount} to accept', + // ── The selection count is not stated here ── + // + // It used to append `· 5 selected`, which made this the **third** place + // the same number appeared: the select-all row directly beneath says + // "All 5 selected · Clear" and the floating bar says "Accept · 5". With a + // state chip now leading the line it also no longer fits — the tail + // ellipsised to `5 orders · 5 bags · 5…`, so the one copy that truncated + // was the redundant one. + // + // Involvement is still shown, in the register that costs no width: the + // whole line turns brand red while anything here is ticked. + ].join(' · '); + } + + Widget _header(BuildContext context, int chosen) { + final rejected = group.flat && group.state == StopState.rejected; + + // ── Two columns at reading scale, one at accessibility scale ── + // + // The right-hand journey column is worth a third of the row's width, and at + // 1.0× that is a bargain: it buys a fixation-free read and a scan column + // down the run. At 2.0× on a 320pt phone the same column is most of the + // screen, and the composition inverts — the kitchen name, which is the one + // thing that must never truncate, ellipsised to "Vid…" while a distance sat + // beside it in full. + // + // So past ~1.35× the layout *changes shape* rather than being squeezed into + // the same shape: the journey folds into the load line as a tail, where it + // can ellipsise last instead of the destination ellipsising first. This is + // the responsive break, and it is set on text scale rather than on width + // because the pressure comes from type, not from the device. + final scaled = MediaQuery.textScalerOf(context).scale(14); + final twoColumn = scaled <= 14 * 1.35; + + final journey = rejected || group.isSettled ? '' : group.journeyLabel; + final showJourney = journey.isNotEmpty && twoColumn; + + final load = [ + _loadLine(chosen), + if (journey.isNotEmpty && !twoColumn) journey, + ].join(' · '); + + final bool tickable = + group.flat && _isChoosable(group.state) && onToggleSelect != null; + final bool ticked = + tickable && selectedIds.contains(stopIdOf(group.stops.first)); + + // ── One gesture, one meaning ── + // + // The header's tap used to *select* on a flat group and *expand* on a + // grouped one, so the same object did two different things depending on + // data the rider cannot see. Worse, on a kitchen the only way to reach the + // orders was a tap that, one route later, would tick something. + // + // Now the header is unambiguously **disclosure**: it opens and closes the + // place. Selecting is a separate control on the order's own node, and + // opening details is the order row itself. Three responsibilities, three + // targets, none of them overlapping. A flat group has nothing to disclose, + // so its header opens the detail sheet — which is the same promise the + // chevron on it makes. + // ── The header's label wraps the *content*, not the rail ── + // + // It wrapped the whole row with `excludeSemantics: true`, which drops every + // descendant's semantics — and the rail is now a control in its own right. + // A flat stop's tick therefore announced nothing at all: the one node on the + // screen a reader most needs to find had been absorbed into the sentence + // describing the row it sits beside. + // ── The card opens the place; the chevron opens the list ── + // + // Tapping the header used to expand. That made the *whole* card a + // disclosure control and left no way to ask the obvious question — "where + // is this and what am I picking up?" — without first reading a list of + // customers. + // + // So the two are separated by where you touch. The card body opens the + // **pickup preview**: the leg drawn from where he is standing to the + // counter, and the manifest waiting on it. The chevron, which is the one + // element that has always looked like a disclosure, opens the orders. Each + // affordance now does the thing it looks like it does. + // ── And the chevron is a *sibling* of that body, not a child of it ── + // + // It began nested inside the header's own `InkWell`, and the tap went + // nowhere: two tap recognizers over the same pixels, and the arena resolved + // to neither — so the one control on the card that looks like a disclosure + // did nothing at all. Laid out side by side, every pixel belongs to exactly + // one gesture and there is no arena to resolve. + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // A flat group's node doubles as its selection control — it is a + // stop, so ticking it is ticking the order. + _Spine( + node: _GroupNode( + state: group.state, + ordinal: ordinal, + current: isCurrent, + selected: ticked, + ), + onNodeTap: tickable + ? () => onToggleSelect!(stopIdOf(group.stops.first)) + : null, + nodeSelected: ticked, + nodeSize: _GroupNode.size, + // The card's own lead-in (14) plus half the name's line box — + // `cardTitle` at 19sp for the group being worked, `body` at 16 for + // the rest — so the disc sits on the name's centre line rather than + // four points above it. + centre: 14.h + (isCurrent ? 11.5.h : 10.h), + lineAbove: false, + // ── The thread starts at the orders, not at the header ── + // + // Running it down from the place node meant it crossed the + // action row, where it appeared beside a button as a stray red + // stub with nothing at either end. The card is what groups the + // place with its orders now; the spine's job is narrower — it + // threads the *steps* — so it begins where they do. + lineBelow: false, + color: _lineColor, + ), + Expanded( + child: InkWell( + // The body opens the **pickup preview** — where this place is and + // what is waiting on it — which is the question the card is for. + // Disclosure is the chevron's job beside it. + // ── The card is the disclosure ── + // + // It briefly opened the pickup preview and left disclosure to a + // chevron beside it. That put the thing the rider does most — + // look at what is at a counter — behind a 30pt disc, and the + // card's whole surface behind a sheet. Tapping a place shows its + // orders; the preview has its own "i" in the corner, where it + // does not compete for the same pixels. + onTap: group.flat + ? (onStopTap == null + ? null + : () => onStopTap!(group.allStops.first)) + : onToggle, + child: Semantics( + button: true, + expanded: group.flat ? null : expanded, + // The two columns are read as one sentence, in the order a + // rider needs them: where, how much, how far. Unaffected by + // the two-column break — a reader hears the same three facts + // either way. + label: [group.name, load, if (showJourney) journey].join(', '), + // The non-flat hint said "Opens the pickup preview" — a + // sentence about a tap this header stopped owning when the + // preview moved to the utility circle. A wrong hint is worse + // than none: a reader acts on it. + hint: group.flat + ? 'Opens stop details' + : (expanded ? 'Hides its orders' : 'Shows its orders'), + excludeSemantics: true, + child: Padding( + // ── The card has a right margin too ── + // + // This was 0 on the right, so the chevron sat flush against + // the card's edge — the one element on the header with + // nothing between it and the outside. A card whose content + // touches its own boundary reads as clipped rather than as + // laid out, and it broke the symmetry with the 14 the action + // row below already used. + // 6 → 2 on the right. The journey column and the utilities + // below it are the card's right-hand landmark, and at 6 they + // sat further in than anything else on the card — a margin + // that reads as a gap rather than as alignment. They move out + // together so the column stays one line. + // The journey column keeps its own 6. It is *type*, and type + // right-aligns on its glyphs — pushed to the card's edge it + // would read as falling off. The utility circles below it go + // flush instead: a disc's silhouette curves away from the + // margin, so it needs to overshoot a text edge by a few points + // to look level with it. Optical alignment, not metric. + padding: EdgeInsets.fromLTRB(2.w, 14.h, 6.w, 12.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // ── Name, then its state ── + // + // "Vidhya Kitchen" and "Accepted" are one statement, + // so they are set as one and ellipsise together. + // + // This pairing cost the name ~70pt of a ~260pt row + // once, and rendered it as "Vid…" — but that was + // while the journey column was a `Flexible` taking + // half the row. Against a fixed 84 block the line is + // name + chip + 84, so the name holds ~170pt at the + // design width and the load below gets the full + // column instead of ellipsising to `5 b…`. + Row( + children: [ + Flexible( + child: Text( + group.name, + // Two lines before it gives anything up. + // The header's own doctrine is that the + // destination is the one thing that must + // never truncate, and one line broke it on + // the first real route: "Gandhipuram Main + // Rd" rendered as "Gandhipuram Main…" — + // the words that name *which* road cut off + // by a rule meant to protect them. Real + // Indian place names are long; the row can + // afford a wrap, it cannot afford a wrong + // guess at a junction. + maxLines: 2, + overflow: TextOverflow.ellipsis, + // ── The active place is bigger than the + // ones behind it ── + // + // Every group used to be set at one size, so + // a five-kitchen route was five equal + // headings and the rider had to work out + // which one he was riding to. The group he + // is on takes the app's `cardTitle`; the + // rest take `body`. Hierarchy that follows + // *state* rather than position is the whole + // reason a route can be read without + // reading every line. + style: + (isCurrent + ? MilerType.cardTitle + : MilerType.body) + .copyWith( + fontWeight: FontWeight.w700, + color: group.isDone || rejected + ? ColorConstants.secondaryText + : ColorConstants.slateText, + decoration: rejected + ? TextDecoration.lineThrough + : null, + ), + ), + ), + ], + ), + // ── The state is not here ── + // + // It had its own line between the name and the load, + // and that line only existed when there *was* a + // state — so an accepted card stood ~18pt taller + // than a pending one, and a run of three kitchens + // stepped up and down as the rider worked it. A card + // that changes height on a state change is a card + // whose controls move under the thumb. + // + // It sits on the action row below instead, in space + // that row was already spending on a `Spacer`. Same + // reading order — identity, load, state — and the + // geometry is now identical in every state. + SizedBox(height: 4.h), + Row( + children: [ + Flexible( + child: Text( + load, + maxLines: 1, + overflow: TextOverflow.ellipsis, + // Level 3, and readable: the load is what + // he counts against a shelf, so it is set + // in the page's mid-tone rather than the + // pale grey reserved for things nobody has + // to act on. + style: MilerType.label.copyWith( + fontWeight: FontWeight.w600, + color: chosen > 0 + ? ColorConstants.primary + : ColorConstants.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + ), + // ── The journey is a column, not a fourth line ── + // + // Distance and ETA were stacked under the load as a third + // and fourth row of small grey text, which is how they + // came to read as metadata: everything in that block was + // left-aligned, so the eye had to walk down four lines and + // parse each one to find the two numbers that answer "how + // far". + // + // They are their own right-hand column now, at the same + // baseline as the name they belong to. Three things follow + // from that and all three are the point: + // + // • **It reads in one fixation.** Identity on the left, + // journey on the right, at a size that carries — the + // same split every mature transit and dispatch product + // lands on, because it is the split the rider's + // question already has. + // • **It removes two rows.** The node is two lines + // instead of four, so a second kitchen fits in the same + // viewport. + // • **It forms a scan column.** Down a multi-kitchen run + // the distances line up on one right edge, so "which is + // nearest" is answered by looking down a column rather + // than reading five blocks. + if (showJourney) ...[ + SizedBox(width: 6.w), + _JourneyColumn( + distance: group.meters != null && group.meters! > 0 + ? RouteMetricsHelper.formatDistance(group.meters) + : null, + ride: group.rideTime > Duration.zero + ? '~${RouteMetricsHelper.formatDuration(group.rideTime)}' + : null, + prominent: isCurrent, + ), + ], + // ── The disclosure says it is one ── + // + // The chevron was removed when the header became the + // toggle, and its *signal* went with it: a collapsed + // place showed nothing that said "there is a list under + // me", so the orders were discoverable only by accident + // — on the control the rider uses more than any other + // on this screen. + // + // It returns as an INDICATOR, not a target. The old bug + // was two tap arenas over one pixel; this glyph is + // passive inside the header's own InkWell, so there is + // exactly one gesture and it owns the whole card. It + // rotates with the state (the app's state duration, no + // bounce), and a flat stop — whose tap pushes the stop + // detail instead — wears the pointing variant, the same + // vocabulary every row of Account uses for "this goes + // somewhere". + SizedBox(width: 4.w), + group.flat + ? Icon( + LucideIcons.chevronRight, + size: 20.sp, + color: ColorConstants.borderStrong, + ) + : AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + child: Icon( + LucideIcons.chevronDown, + size: 20.sp, + color: expanded + ? ColorConstants.secondaryText + : ColorConstants.borderStrong, + ), + ), + ], + ), + ), + ), + ), + ), + // The UNDO that used to sit here is gone with the row it sat on: a + // declined stop files straight to Activity now rather than staying on + // Home waiting to be un-declined. See `TripCard._showsOnHome`. + ], + ), + ); + } + + /// The state, as a word, only once it is worth saying. + /// + /// A pending group has no chip: "Pending" on every group on a fresh morning + /// is a mark repeated down the page that separates nothing. The chip appears + /// when the group starts moving. + /// + /// ── A word, not a pill, and on the load line ── + /// + /// It has been in three places, and the first two both failed by *taking + /// width from something that mattered more*: + /// + /// • **Line one, after the name.** A pill is its text plus 18pt of padding — + /// ~106pt for "Accepted" — and it is unflexed, so the destination gave way + /// to it. "Vidhya Kitchen" rendered as "Vid…". + /// • **Line two, before the load.** Same 106pt, taken from + /// `5 orders · 5 bags`, which ellipsised to `5 orders · 5 b…` — a state + /// pill truncating the one fact that tells the rider how many bags to + /// count off a shelf. + /// + /// It was also briefly *removed* from the current group, on the grounds that + /// the tonal ground, the lit node and the Navigate button already say it. Two + /// of those are colour and the third is a button whose presence depends on a + /// callback, so a route rendered without navigation stated the rung nowhere a + /// screen reader or a grayscale screen could reach. `service_flow_test.dart` + /// caught that, correctly. + /// + /// So: the same word, at half the width, leading the load line — `Accepted · + /// 5 orders · 5 bags` reads as one statement about this counter, which is + /// what it is. Line one is the destination and its journey, and nothing else + /// competes for it. + /// + /// [LiveMark] keeps its filled form. It is the one state the rider must not + /// miss, and it differs in *shape* from every other mark on the page. + Widget? get _chip => switch (group.state) { + // ── Pending is a state, and it is said ── + // + // It used to render nothing, on the grounds that "Pending" on every group + // on a fresh morning is a mark repeated down the page. That was true while + // the word sat on a line of its own — it cost a row per card to say + // "nothing has happened here". On the action row it costs nothing, and the + // silence was worse than the repetition: a rider scanning a run had to + // infer "not mine yet" from the *absence* of a word, which is the one thing + // a glance cannot do. + // ── Pending is neutral, because nothing has gone wrong ── + // + // It was amber, on the argument that undecided work "wants something" and + // should therefore be visible. The argument is right about visibility and + // wrong about the colour: amber is this app's exception hue — a skipped + // stop, a late run, a bag that never arrived — and on a fresh morning every + // kitchen is pending, so the whole screen opened in warning. + // + // A state that is true of *all* the work is not an exception; it is the + // starting condition. Spending the attention colour on it leaves nothing + // to say with when a stop genuinely does go wrong, and a rider who sees + // amber on every group learns to stop reading it. Neutral, and the reason + // it is still readable is that it sits on its own metadata line rather + // than competing with the kitchen name. + StopState.pending => const _StateWord( + label: 'Pending', + color: ColorConstants.secondaryText, + ), + // ── "Accepted" alone does not say how much of the counter he took ── + // + // A place with four orders and one accepted rendered the same word as a + // place with four accepted: the rider had taken a quarter of the counter + // and the card told him only that he had taken *something*. The three he + // still has to decide are in the list under it, but that is a scroll and a + // count, and this word exists precisely so he does not have to do either. + // + // Only where there is more than one order to be a fraction of — a parcel + // route is one order per place, and `Accepted (1 of 1)` is noise. + StopState.accepted => _StateWord( + // Brand only on the group he is riding to; slate on the ones he has taken + // but is not working. Red marks the mission, not the commitment — and it + // is now the only thing on the card that can be confused with amber, so + // the two states read apart at a glance. + // `Accepted (2 of 4)` truncated to `Accepted (2 o…` the moment the row + // also carried Navigate, Call and the "i" — which is every accepted + // group. A state that ellipsises has lost the number it exists to state. + label: group.groupSize > 1 + ? 'Accepted ${group.onRungCount}/${group.groupSize}' + : 'Accepted', + color: isCurrent + ? ColorConstants.primary + : ColorConstants.onSurfaceVariant, + ), + // ── The one the rider must not miss ── + // + // [LiveMark] rather than another word in a pill: it is the app's own mark + // for "this is running", it differs in *form* as well as hue — a filled, + // breathing chip among outlined ones — so it survives sunlight and a + // grayscale screen. The stop under way used to be hidden from Home + // entirely and announced by a red banner over the foot of the page. + StopState.active => const LiveMark(), + StopState.collected || StopState.done => const _StateWord( + label: 'Collected', + color: ColorConstants.acceptGreen, + ), + // ── The two ends of "not going to happen here" ── + // + // Both used to render nothing, so a skipped stop and a declined one were + // indistinguishable from a pending one on the only line that says what a + // stop is doing. They are different facts and they get different marks: + // amber for work still owed, muted grey for work the rider has closed out. + StopState.skipped => const _StateWord( + label: 'Skipped', + color: ColorConstants.warning, + ), + StopState.rejected => const _StateWord( + label: 'Declined', + color: ColorConstants.secondaryText, + ), + }; + + /// The spine, coloured by what the group is doing. + /// + /// Three states, and no more: behind him, under way, ahead. A route that + /// tinted every rung differently would be a legend the rider has to learn; + /// what he needs from the line is "where does the live part start". + /// The spine, on the same rule as the node it hangs from. + /// + /// This tested `isLive || (isCurrent && accepted)` while [_GroupNode] tests + /// `current && !done`, so a current group the rider had not ticked yet drew + /// a brand node on a hairline-grey line — the mark and its own spine + /// disagreeing about whether this is the group he is on. One rule, read the + /// same way in both places: the current group is lit until it is finished, + /// and then it is green. + Color get _lineColor => group.isDone + ? ColorConstants.acceptGreen.withValues(alpha: 0.35) + : isCurrent + ? ColorConstants.primary.withValues(alpha: 0.30) + : ColorConstants.borderSubtle; + + // ── The body: the journey, the selection helper, then the milestones ───── + + Widget _body(BuildContext context, List manifest, int chosen) { + final ids = _choosableIds; + + final selectable = _isChoosable(group.state) && onToggleSelect != null; + + // ── The orders are a panel *inside* the card ── + // + // The ground ran the full width of the card and squared off against its + // edges, so it read as a band stuck to the bottom of the header rather than + // as the card's contents — the orders looked like they had spilled out of + // the thing that owns them. + // + // Inset on all four sides with the app's inner radius, it is unambiguously + // *in* the card: a panel with the place's name above it. That is one nested + // surface, not "a collection of cards inside a card" — the orders themselves + // stay rows on it, with no frame of their own. + // + // The inset moves the step rail right by [_panelInset], so the order nodes + // no longer share an axis with the place node above them. That is correct + // now: the spine stopped running between them when the card became the thing + // that groups them (see the note at `lineBelow`), so there is no line to + // bend — and a nested panel that ignored its own container's inset would + // read as broken. `stop_row_alignment_test.dart` pins the new relationship. + return Padding( + padding: EdgeInsets.fromLTRB( + _panelInset, + 0, + _panelInset, + _grounded ? _panelInset : 0, + ), + // ── The well is back, because the ground changed under it ── + // + // The tinted panel behind the orders was removed when Home's rows sat + // directly on a white page — a second rectangle then had nothing to + // separate itself from. The route now stands on a white [MilerPanel], + // which put the open list back to white-on-white: an accordion whose + // open state looked exactly like its closed neighbours, which is the + // flatness being fixed on this screen. + // + // So the open orders sit in a canvas-toned well — the one grey that + // measurably stands on this white (1.29:1) and the same tone the page + // ground already is, so the well reads as the card opening *down to the + // page* rather than as a new object. One nested surface, the ladder's + // permitted inset; the rows on it still have no frames of their own. + child: Container( + decoration: _grounded + ? BoxDecoration( + color: MilerSurface.canvas, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ) + : null, + // 8 horizontal, not 10: at 320pt and 2.0× text the two extra points + // per side were the difference between fitting and a 31px overflow — + // the accessibility sweep in `home_structure_test` is the referee. + padding: _grounded + ? EdgeInsets.fromLTRB(8.w, 8.h, 8.w, 10.h) + : EdgeInsets.only(top: 4.h, bottom: 8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── The journey action lives with the orders ── + // + // It sat on the collapsed card, which read well but put the + // screen's loudest control on every place in the run at once — + // three kitchens, three red buttons, none of them the one he is + // working. Inside the dropdown there is exactly one on screen, + // because exactly one place is open: opening a counter *is* the + // rider saying "this is the one", and the ride to it is what he + // does next. + // ── One control row, not two ── + // + // The journey button sat on its own line *above* the select-all, so + // opening a kitchen produced two stacked rows of chrome before the + // first order — and they are the two things a rider does with an + // open counter, which makes them one row. Select-all leads: it is + // what he does first and the quieter of the two. Navigate closes the + // row on the right, where the primary action belongs. + // No spine on this row. It sits above the first order, so a line + // through it is a length of route drawn above node 1 — the thing + // the rail is supposed to start at. + if (_railRow(context, ids, chosen) != null) + _railed( + _railRow(context, ids, chosen)!, + line: false, + // The tick sits where the order nodes sit, so the label lands + // on the same axis as the customer names it gathers. + node: _selectAllMark(ids, chosen), + nodeSize: _StepNode.size, + ), + for (final (i, line) in manifest.indexed) + PickupTimelineStep( + line: line, + ordinal: i + 1, + state: group.stateOf(line.orderId), + selected: selectedIds.contains(line.orderId), + busy: busyIds.contains(line.orderId), + selectable: selectable, + // The spine closes inside the card now: the run's continuity is + // carried by the cards themselves, so the last order's connector + // has nothing below it to reach. + first: i == 0, + last: i == manifest.length - 1, + lineColor: _lineColor, + onTap: () => onToggleSelect?.call(line.orderId), + onOpen: onStopTap == null ? null : () => onStopTap!(line.stop), + onUnreject: onUnreject == null + ? null + : () => onUnreject!(line.stop), + ), + ], + ), + ), + ); + } + + /// `Select all`, at the head of the orders it selects. + /// + /// ── Why the journey action left this row ── + /// + /// The two shared a line: a bare text link and a filled maroon button, side by + /// side, inside the panel that holds the orders. Two problems, and the second + /// is the one that made it feel wrong without being nameable. + /// + /// • **Two control languages.** A link and a solid button read as belonging + /// to different systems when they sit at the same level. + /// • **They are about different things.** `Select all` is about the *orders* + /// — it is the header of the list under it. `Navigate` is about the + /// *place*: it has nothing to do with which bags he ticks, and it was + /// sitting inside the container for them, as the loudest object in it. + /// + /// So they split by subject. The journey joins the call and the details in the + /// card's own action row — the three things a rider does *about a place* — + /// where it is the primary among them. This row keeps only what belongs to the + /// list it heads. + /// + /// Null when there is nothing to select, so an empty row never reserves height + /// above the first order. + /// The select-all's tick, drawn in the rail's node column. + Widget _selectAllMark(List ids, int chosen) { + final all = chosen == ids.length && ids.isNotEmpty; + final some = chosen > 0 && !all; + final accent = (all || some) + ? ColorConstants.primary + : ColorConstants.secondaryText; + return Icon( + all + ? LucideIcons.circleCheck + : some + ? LucideIcons.circleMinus + : LucideIcons.circleCheck, + size: 18.sp, + color: accent, + ); + } + + Widget? _railRow(BuildContext context, List ids, int chosen) { + final selectAll = !group.flat && ids.isNotEmpty && onSelectAll != null + ? SelectAllAction( + total: ids.length, + picked: chosen, + scoped: true, + onTap: () => onSelectAll!(chosen == ids.length ? const [] : ids), + ) + : null; + if (selectAll == null) return null; + + return Padding( + // 2, matching the order tiles below it, so the panel has one right edge. + padding: EdgeInsets.only(bottom: 8.h), + child: selectAll, + ); + } + + /// The inset that turns the expanded ground into a panel *inside* the card. + static double get _panelInset => 10.w; + + /// Anything that hangs under the header on the group's own spine, so the line + /// runs unbroken past it rather than restarting at the next node. + /// [line] false reserves the rail column but draws nothing in it, so a row + /// that sits ABOVE the first order keeps the panel's left edge without + /// putting a length of spine above node 1 — see the note on + /// [PickupTimelineStep.first]. + /// [node] draws in the rail's own column, so a row that heads the list can + /// put its mark on the node axis instead of inline — where it would push its + /// own label off the axis the list keeps. See [_railRow]. + Widget _railed( + Widget child, { + double? bottom, + bool line = true, + Widget? node, + double nodeSize = 0, + }) => IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Spine( + node: node ?? const SizedBox.shrink(), + nodeSize: nodeSize, + lineAbove: line, + lineBelow: line, + color: _lineColor, + ), + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(2.w, 0, 0, bottom ?? 0), + child: child, + ), + ), + ], + ), + ); +} + +/// One order, as a milestone. +/// +/// ── What is deliberately not on it ── +/// +/// The address, the phone, the parcel counts, the ETA and the type. All of it +/// was on the card, and putting it back here would rebuild the card with a line +/// down its left-hand side. What a rider reads off this row is *who* and *which +/// bag* — the two facts he matches against a shelf — and everything else is one +/// tap away in the sheet that already exists. +class PickupTimelineStep extends StatelessWidget { + final BagLine line; + + /// 1-based position **within its own pickup group**, which is also its bag + /// position when the kitchen printed no label. Never route-global. + final int ordinal; + + /// First order in the group. + /// + /// ── The line starts at 1, and ends at the last step ── + /// + /// `lineAbove` was hard-coded true, so the first order drew a connector + /// *upward* out of its own node — into the select-all row above it, which was + /// drawing its own segment to meet it. The result was a length of spine + /// hanging above node 1, connecting it to a control rather than to a stop. + /// + /// A connector means "there is another stop that way". Above the first one + /// there is not. [last] already closed the bottom end for the same reason; + /// this closes the top. + final bool first; + + final StopState state; + final bool selected; + final bool busy; + + /// Whether ticking is meaningful on this rung. + final bool selectable; + + final bool last; + final Color lineColor; + + /// Ticks this order. Reached through the **rail**, not through the row. + final VoidCallback onTap; + + /// Opens the order's detail sheet. Reached through the **row**. + final VoidCallback? onOpen; + + /// Puts a declined order back on offer. Only ever drawn on a rejected step. + final VoidCallback? onUnreject; + + const PickupTimelineStep({ + super.key, + required this.line, + required this.ordinal, + required this.state, + required this.selected, + required this.busy, + required this.selectable, + this.first = false, + required this.last, + required this.lineColor, + required this.onTap, + this.onOpen, + this.onUnreject, + }); + + @override + Widget build(BuildContext context) { + final customer = line.customer.isEmpty ? 'Stop $ordinal' : line.customer; + // The area the bag goes to, not the whole postal address: a rider grouping + // four bags out of one kitchen is deciding on neighbourhoods, and a full + // address would wrap and bury the name above it. First component of the + // drop, which is how these are written — `Gandhipuram, Coimbatore`. + final dropArea = _areaOf(line.stop); + final rejected = state == StopState.rejected; + final canOpen = onOpen != null && !busy; + + /// Taken, and not yet worked. The row's whole treatment turns on this: a + /// counter of four with two accepted has to answer "which two" before the + /// rider has finished looking at it. + final accepted = state == StopState.accepted; + + // ── Three gestures, laid out side by side ── + // + // The row's whole surface used to open the detail sheet. That gave the + // *least* frequent action on a step — reading an address — the largest + // target on it, while ticking, which a rider does five times per counter, + // was a 44pt rail he had to aim at. + // + // So the row body ticks, and the sheet gets an affordance that looks like + // one: the bag-and-id block with a chevron on it, in the right corner. Each + // is a **sibling**, never nested — two tap recognizers over the same pixels + // resolve to neither, which is how the card's chevron came to be completely + // dead for a build. + // + // ┌────┬───────────────────────┬───────────────┐ + // │rail│ Joe Mathew │ Bag 1 › │ + // │tick│ (tick) │ DG-1001 │ ← opens the sheet + // └────┴───────────────────────┴───────────────┘ + final Widget node = _StepNode( + ordinal: ordinal, + state: state, + selected: selected && selectable, + busy: busy, + selectable: selectable, + ); + + // ── Each order is its own tile ── + // + // The steps were transparent rows on the group's grey panel, separated by + // nothing but air, which read as one continuous list rather than as five + // things the rider counts one at a time. A white tile per order on that grey + // gives each one an edge without a border, a shadow or any of the weight the + // route spent three redesigns shedding — the panel is the container, and + // these are the items in it. + // + // The rail stays *outside* the tile, on the panel's own ground, so the spine + // still threads them. + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Spine( + node: node, + // The whole rail cell is a second tick target — see [_Spine]. + // Silent, because the tile beside it announces the same tick with + // the order's own name on it, and a reader should not meet the same + // control twice on one row. + onNodeTap: selectable && !busy ? onTap : null, + nodeSelected: selected, + announceNode: false, + nodeSize: _StepNode.size, + // See [first]: a connector means "another stop that way", and above + // the first order there is not one. + lineAbove: !first, + lineBelow: !last, + color: lineColor, + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: 6.h, right: 2.w), + // ── No tile behind an order ── + // + // Three grounds used to run here — brand for selected, slate for + // accepted, white for pending — each on its own rounded tile. + // Every one of them was a rectangle inside the panel inside the + // card, and with both of those gone the tiles are five loose + // shapes on open ground: a list that reads as five objects + // rather than as five steps of one visit. + // + // Both facts are still carried, and by the thing that is already + // per-order: the **node**. Selected is a filled brand disc with + // a tick; accepted is a filled slate disc; pending is an + // outlined ring with its sequence number. Shape and fill, which + // survive sunlight — and the name's weight follows, so the row + // reads decided or undecided without a wash across it. + child: SizedBox( + width: double.infinity, + child: Row( + children: [ + // ── The body ticks ── + // + // The commonest action on a step, so it gets the largest + // target: everything left of the bag block. + Expanded( + child: Semantics( + button: true, + checked: selectable ? selected : null, + label: selectable + ? 'Select $customer, ${line.bag}' + : '$customer, ${line.bag}', + excludeSemantics: true, + // ── The body selects; the corner opens ── + // + // Selecting is what a rider does to an order row five + // times per counter; reading its address is what he + // does to one of them occasionally. The frequent action + // gets the large target — the whole row — and the + // occasional one gets the block that already looks like + // a way in: the bag, the order id and a chevron in the + // right corner ([_OrderOpenBlock]). + // + // The two are **siblings**, never nested: two tap + // recognisers over the same pixels resolve to neither, + // which is how the card's chevron came to be dead for a + // build. + // + // It falls back to opening only where there is nothing + // to select, so a settled row is never inert. + child: InkWell( + onTap: selectable && !busy + ? onTap + : (canOpen ? onOpen : null), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + child: Padding( + padding: EdgeInsets.fromLTRB(12.w, 9.h, 8.w, 9.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + customer, + maxLines: 1, + overflow: TextOverflow.ellipsis, + // ── A child of the kitchen, not a sixth kitchen ── + // + // 15sp w500 in the page's mid-tone keeps a name + // comfortably readable outdoors while letting the + // header lead. Ticking promotes it to full slate, + // because a selected row *is* the thing he is + // acting on. + // Weight is the third channel, after the node's + // shape and the tile's ground: a taken order is + // set at w700 in full slate, an undecided one at + // w500 in the mid-tone. Three channels means the + // distinction survives losing any one of them. + style: MilerType.body.copyWith( + fontSize: 15.sp, + fontWeight: selected || accepted + ? FontWeight.w600 + : FontWeight.w500, + color: rejected + ? ColorConstants.secondaryText + : selected || accepted + ? ColorConstants.slateText + : ColorConstants.onSurfaceVariant, + decoration: rejected + ? TextDecoration.lineThrough + : null, + ), + ), + // ── Where the bag is going ── + // + // The row carried the customer and the booking + // reference. The reference is what the office reads + // down a phone once, when something has gone wrong; + // the *drop area* is what the rider is deciding on + // every time he looks at this list — it is how he + // knows whether Joe and Priya are the same trip out + // of the kitchen or opposite ends of the city. + // + // So the id moves to the detail sheet, where the + // rare question lives, and the area takes the line + // under the name. Absent rather than blank when the + // payload carries no drop. + if (dropArea.isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + dropArea, + // ── The area wraps; it does not clip ── + // + // At 2× `Peelamedu` became `SNS…`, which + // is not a shorter answer to "where is + // this going" — it is no answer. Height is + // the cheapest thing on this row to spend, + // and the row is already allowed to grow. + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.5.sp, + color: rejected + ? ColorConstants.borderStrong + : ColorConstants.secondaryText, + ), + ), + ], + ], + ), + ), + ), + ), + ), + // The UNDO branch is gone: a declined order leaves Home for + // Activity the moment it is declined, so there is no row + // here to un-decline from. See `TripCard._showsOnHome`. + // + // ── The bag, the id and the chevron are one target ── + // + // "Which bag" and "which order" are the two things a rider + // reads off a step, and "tell me more about this order" is + // the action they lead to — so they are one block and one + // gesture, marked by the chevron that has always meant + // *opens*. + _OrderOpenBlock( + bag: rejected ? 'Rejected' : line.bag, + orderId: line.orderId, + selected: selected, + onTap: canOpen ? onOpen : null, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +/// `Bag 1 ›` over the order's own id — the step's way into its detail sheet. +/// +/// One block rather than three loose elements, because they answer one +/// question between them: *which order is this, and show me it*. The chevron is +/// the affordance; the bag and the id are what it is about. +/// The neighbourhood a stop is in. One implementation, shared with the +/// Deliveries queue — see [areaOf]. +String _areaOf(Map stop) => areaOf(stop); + +class _OrderOpenBlock extends StatelessWidget { + final String bag; + final String orderId; + final bool selected; + final VoidCallback? onTap; + + const _OrderOpenBlock({ + required this.bag, + required this.orderId, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final id = displayOrderId(orderId); + + return Semantics( + button: onTap != null, + label: id.isEmpty ? bag : '$bag, order $id', + hint: onTap == null ? null : 'Opens order details', + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Padding( + padding: EdgeInsets.fromLTRB(4.w, 8.h, 8.w, 8.h), + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.25, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + // Capped so a kitchen's own long printed label (`DG-100042-A`) + // cannot take the customer's name with it. + // + // ── And the column stops growing before the name does ── + // + // `Bag 1` is a shelf reference: it is *compared*, not read as + // prose, so it does not need to keep pace with a rider's text + // size. Left to scale it took ~150pt of a 320pt row at 2×, and + // the thing that yielded was the customer's name — which is + // the one item on the row the brief says must yield last. + // + // Clamped at 1.25×, which keeps it comfortably legible against + // a physical shelf while handing the rest of the row back to + // the name and the area. + constraints: BoxConstraints(maxWidth: 104.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + // ── One order, one bag ── + // + // Read straight off [BagManifest]: never a quantity, never a + // route-global number. Right-aligned on every row so a + // column of them lines up against a shelf being counted. + // ── It ranks under the name, not over it ── + // + // Drawn at w700 in full slate this sat *above* the + // customer, who is w500 in the mid-tone until he is + // ticked. So on a fresh route — every row undecided, + // which is how the screen opens — the boldest thing on + // every line was a shelf number, and the eye read a + // column of bags before it read a single person. + // + // The reference still needs to be scannable against a + // physical shelf, so it keeps a weight of its own: it + // steps to w600 in the mid-tone, one rung under the + // undecided name, and follows the row up to slate the + // moment the row is taken. The bag never leads; it + // travels with whatever the row currently is. + // ── And it is a tag, because it is a label ── + // + // A bag number is a physical reference — the sticker on + // the shelf — not a sentence, and every delivery app the + // rider carries sets that kind of fact as a small tag. + // The fill is the page's canvas tone, the only grey that + // measurably stands on this white panel (1.29:1), so the + // tag reads as an object without borrowing a border. It + // still ranks under the name: quiet ink at rest, brand + // tint the moment the row is ticked. + Container( + padding: EdgeInsets.symmetric( + horizontal: 7.w, + vertical: 3.h, + ), + decoration: BoxDecoration( + // White, because these rows live inside the canvas + // well: layer-1-on-canvas, the same 1.29 step the + // rest of the system stands on. (Canvas-on-canvas + // was invisible the moment the well returned.) + color: selected + ? ColorConstants.primary.withValues(alpha: 0.10) + : MilerSurface.working, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Text( + bag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.label.copyWith( + fontSize: 12.sp, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w600, + color: selected + ? ColorConstants.primary + : ColorConstants.secondaryText, + ), + ), + ), + // ── The reference is not on the row any more ── + // + // It is what the office reads down a phone once, when + // something has gone wrong. The line it occupied now + // carries the drop area, which is what the rider is + // deciding on every time he looks at the list. The id is on + // the detail sheet this block opens, and on the Activity + // record — both places the rare question is actually asked. + ], + ), + ), + if (onTap != null) + Icon( + LucideIcons.chevronRight, + size: 18.sp, + color: ColorConstants.borderStrong, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The order id, as every screen reads it. +String stopIdOf(Map stop) => + (stop['orderid'] ?? '').toString(); + +/// The order id as a rider should *see* it, or empty when there is nothing +/// worth showing him. +/// +/// ── Why a placeholder is worse than a blank ── +/// +/// The meal-run fixture mints ids as `MOCK-M-2001` (see `meal_run_mock.dart`), +/// and those were rendering on the step beside real ones like `AM-2001`. A +/// rider reading an order number off his phone to a hub on the telephone has no +/// way to know which of the two is real — and the one time it matters is the one +/// time something has already gone wrong. +/// +/// So a development placeholder shows nothing at all. The row simply carries its +/// bag, which is what it carried before ids existed. +String displayOrderId(String raw) { + final id = raw.trim(); + return id.toUpperCase().startsWith('MOCK') ? '' : id; +} + +/// The number the rider dials for a pickup place. +/// +/// One reader, so the Call control cannot end up offered on a stop that carries +/// no number — a dead button on a card is worse than no button, because the +/// rider only finds out it is dead while standing at a locked shutter. +String stopPhoneOf(Map stop) => + (stop['pickupcontactno'] ?? stop['PickupContactNo'] ?? '') + .toString() + .trim(); + +// ───────────────────────────────────────────────────────────────────────── +// The spine +// ───────────────────────────────────────────────────────────────────────── + +/// The rail cell: a 2pt line and a node held at a fixed distance from the top +/// of its row, so nodes stay aligned whatever height the content beside them +/// turns out to be. +/// +/// `IntrinsicHeight` above it is what lets the line know how far to run. It is +/// the one measured layout on the screen and it is cheap here — a row is a line +/// of text and a dot — but it is the reason a step must not grow into a card. +class _Spine extends StatelessWidget { + final Widget node; + final double nodeSize; + final bool lineAbove; + final bool lineBelow; + final Color color; + + /// Makes the whole rail cell the selection control. + /// + /// ── Why the target lives here and not on the node ── + /// + /// The mark is 13pt. Wrapping *it* in an `InkResponse` gives a 13×13 hit area, + /// which is a quarter of the app's own floor and unhittable with a glove on. + /// The rail, though, is 44 wide by the full height of the row — so the cell + /// takes the gesture and the mark is simply what it draws. "Tap the dot" then + /// means "tap anywhere on the rail beside this order", which is what a rider + /// aiming at it actually does. + /// + /// Null leaves the cell inert, so a node that is not a control cannot swallow + /// taps meant for the row. + final VoidCallback? onNodeTap; + + /// Announced state for [onNodeTap]. Ignored when the cell is inert. + final bool nodeSelected; + + /// Whether the rail announces the selection, or leaves it to a larger target + /// beside it. See [_SelectableNode.announce]. + final bool announceNode; + + const _Spine({ + required this.node, + required this.nodeSize, + required this.lineAbove, + required this.lineBelow, + required this.color, + this.onNodeTap, + this.nodeSelected = false, + this.announceNode = true, + this.centre, + }); + + /// Width of the rail column. Every node is centred in it, so the group node + /// and the step nodes share one vertical axis. + /// + /// **Exactly one tap target wide.** It was 30, which was fine while the rail + /// was decoration — but the rail now *is* the selection control for an order, + /// and a 30pt-wide target is one a gloved thumb misses. At 44 it is the app's + /// own floor in the narrow axis and a whole row tall in the other, so "tap the + /// dot" is a gesture the rider can make without looking. + /// + /// It also buys the indentation the steps wanted anyway: order rows now start + /// a clear 44 in from the card edge, which is what makes them read as contents + /// of the place above rather than as five more places. + static double get width => ButtonSizes.minTapTarget.w; + + /// Distance from the top of a row to the centre of its node. + /// + /// One constant no longer works, because the two rows it serves no longer + /// have the same lead-in: a step's text starts 11 into a tile, a place's + /// starts 14 into the card and is set six points larger. Sharing 21 put the + /// numbered disc visibly *above* the kitchen name it belongs to — the kind of + /// four-point drift that reads as carelessness without anyone being able to + /// name it. + /// + /// So each row passes the centre of its own first line. `nodeCentre` is the + /// step's, and stays the default. + static double get nodeCentre => 21.h; + + /// Where the caller wants the node centred, if not [nodeCentre]. + final double? centre; + + double get _centre => centre ?? nodeCentre; + + /// 1.5, not 2. The line is a *thread* the nodes hang on, and at 2pt in a + /// hairline-bordered page it was the heaviest rule on the screen — it read as + /// a divider that happened to have circles on it rather than as a route. + static const double lineWidth = 1.5; + + @override + Widget build(BuildContext context) { + final axis = width / 2; + final half = nodeSize / 2; + + return SizedBox( + width: width, + child: Stack( + children: [ + if (lineAbove) + Positioned( + left: axis - lineWidth / 2, + top: 0, + height: (_centre - half).clamp(0.0, double.infinity), + width: lineWidth, + child: ColoredBox(color: color), + ), + if (lineBelow) + Positioned( + left: axis - lineWidth / 2, + top: _centre + half, + bottom: 0, + width: lineWidth, + child: ColoredBox(color: color), + ), + if (nodeSize > 0) + if (onNodeTap != null) + // The control owns the cell; the mark is drawn where it would + // have been anyway, so nothing moves — only the hit area grows. + Positioned.fill( + child: _SelectableNode( + selected: nodeSelected, + onTap: onNodeTap!, + announce: announceNode, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only(top: _centre - half), + child: SizedBox( + width: nodeSize, + height: nodeSize, + child: node, + ), + ), + ), + ), + ) + else + Positioned( + left: axis - half, + top: _centre - half, + width: nodeSize, + height: nodeSize, + child: node, + ), + ], + ), + ); + } +} + +/// The place node: a numbered disc that fills in as the group progresses. +/// +/// ── One red node on the screen, at most ── +/// +/// Every accepted group used to take the brand ring. On a morning where the +/// rider has accepted his whole route that is five red nodes down the page, all +/// claiming to be the important one — and the answer to "which am I riding to" +/// went back to reading the list. Red stopped meaning anything because +/// everything had it. +/// +/// The rule now is that the brand marks **the mission, not the commitment**: +/// +/// ``` +/// current + live ● filled brand — he is standing there +/// current + accepted ◉ brand ring, core — this is the one he rides to +/// accepted, not current ○ slate ring — his, but not yet +/// pending ○ hairline ring — not decided +/// collected / done ✓ filled green — behind him +/// ``` +/// +/// So at most one node on the screen is red, and it is the answer to the +/// question the screen exists for. The rest differ in fill and weight, which is +/// what keeps them legible in sun and in grayscale. +class _GroupNode extends StatelessWidget { + final StopState state; + final int ordinal; + + /// The group the run is currently on — see [_RouteTimelineState]. + final bool current; + + /// A flat group's node doubles as its selection control, so it has to be able + /// to show a tick like a step's does. + final bool selected; + + const _GroupNode({ + required this.state, + required this.ordinal, + required this.current, + this.selected = false, + }); + + static double get size => 28.w; + + @override + Widget build(BuildContext context) { + final done = state == StopState.collected || state == StopState.done; + final live = state == StopState.active; + final taken = state == StopState.accepted; + + // ── Exactly one red node on the route, and it is the next one ── + // + // This was `current && (live || taken)`, so the brand only arrived once + // the rider had already committed to the stop. On a fresh route every + // stop is pending — which is how the screen opens, and what a rider looks + // at most of the day — so nothing on it was red and stop 1 was drawn + // exactly like stop 7. The screen answered "what is on today" and not + // "where am I going", which is the question it is opened with. + // + // The mission is the group the run is *on*, decided or not. That gives + // Home one brand anchor, always present and always in one place, which is + // what the colour is for. The other half of the old rule is untouched: a + // stop taken on but not being ridden to stays slate, because committed is + // a statement and not a destination. + final bool mission = current && !done; + + final Color accent = selected + ? ColorConstants.primary + : done + ? ColorConstants.acceptGreen + : mission + ? ColorConstants.primary + : taken + ? ColorConstants.onSurfaceVariant + : ColorConstants.borderStrong; + + final filled = done || live || selected; + + // The ring may be a hairline grey, but the number on it is a label the + // rider reads — it takes a readable tone rather than the ring's. + final Color numeral = filled + ? ColorConstants.onAccent + : mission + ? ColorConstants.primary + : ColorConstants.secondaryText; + + return AnimatedContainer( + duration: DesignConstants.motionState, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled ? accent : ColorConstants.pureSurface, + shape: BoxShape.circle, + border: Border.all(color: accent, width: mission ? 2.5 : 2), + // The one node the rider is riding to lifts off the page. A halo rather + // than a shadow: it is the brand at 12%, so it reads as the node being + // *lit* rather than as another elevated object on a flat screen. + boxShadow: mission && !live + ? [ + BoxShadow( + color: ColorConstants.primary.withValues(alpha: 0.12), + blurRadius: 6, + spreadRadius: 2, + ), + ] + : null, + ), + child: done || selected + ? Icon(LucideIcons.check, size: 14.sp, color: ColorConstants.onAccent) + : Text('$ordinal', style: MilerType.figure(12, color: numeral)), + ); + } +} + +/// The order node: small, and the selection affordance when one is meaningful. +/// +/// There is no permanent checkbox on a step. At rest it is a dot; ticked it is +/// a filled check. The row is the target, so the mark can be small. +/// +/// ── Six states, told apart by shape ── +/// +/// ``` +/// pending ○ hairline ring, hollow +/// selected ✓ filled brand, white tick +/// active ◉ brand ring with a brand core — this one, now +/// collected ✓ filled green +/// skipped ! amber ring, amber bang +/// rejected ✕ grey ring, and the row is struck through +/// ``` +/// +/// Every one of them differs in **fill or glyph** and not only in hue, which is +/// the difference between a route a rider can read in direct sun and one he has +/// to take his glove off and squint at. Colour agrees with the shape; it never +/// carries a state alone. +class _StepNode extends StatelessWidget { + final int ordinal; + final StopState state; + final bool selected; + final bool busy; + + /// Whether ticking this step is meaningful right now. Drives the ring tone — + /// a step that can be chosen looks like an empty control, one that cannot + /// looks like a bullet. + final bool selectable; + + const _StepNode({ + required this.ordinal, + required this.state, + required this.selected, + required this.busy, + this.selectable = false, + }); + + /// 16 → 13 → 18 → 22. It shrank to 13 while it was a bare dot, which was + /// right for a mark that said nothing. It carries the step's *number* now, and + /// at 18 the figure inside it was 9sp — below the app's own legibility floor, + /// on the one glyph a rider matches against a printed bag. 22 carries an 11sp + /// numeral and still reads as clearly subordinate to the place node's 28. + static double get size => 22.w; + + @override + Widget build(BuildContext context) { + if (busy) { + return Padding( + padding: EdgeInsets.all(1.w), + child: CircularProgressIndicator( + strokeWidth: 2, + color: ColorConstants.primary, + ), + ); + } + + final done = state == StopState.collected || state == StopState.done; + final rejected = state == StopState.rejected; + final skipped = state == StopState.skipped; + final live = state == StopState.active && !selected; + final accepted = state == StopState.accepted; + + final Color accent = selected + ? ColorConstants.primary + : done + ? ColorConstants.acceptGreen + // Settled, not finished. Slate rather than green — green is the app's + // "done" signal and this order has not been collected yet — and it is + // the same ink the name beside it is promoted to. + : accepted + ? ColorConstants.onSurfaceVariant + : skipped + ? ColorConstants.warning + : live + ? ColorConstants.primary + : rejected + ? ColorConstants.secondaryText + // A step the rider *can* tick reads as an empty control rather than as + // a bullet: the darker ring is what says "there is a decision here". + : selectable + ? ColorConstants.onSurfaceVariant + : ColorConstants.borderStrong; + + // ── Accepted and pending were the same mark ── + // + // A counter with four orders where the rider had taken two drew four + // identical hollow rings. The one question a rider asks of an open group — + // *which of these are already mine* — had no answer on the screen at all, + // and he had to remember. + // + // `accepted` joins the filled set. Filled-versus-hollow is a **shape** + // difference, so it survives sunlight, grayscale and colour-blindness in a + // way a second hue never would, and it says the right thing: a filled mark + // down the route means "this one is accounted for". + // + // It keeps its **number**. The numeral is the order's bag identity, matched + // against a printed label on a shelf, and it must not change or disappear + // because the rider accepted it — see [BagManifest]. So accepted is a + // filled disc with a white numeral, not a tick: a tick would claim work + // that has not happened. + final filled = selected || done || accepted; + + return AnimatedContainer( + duration: DesignConstants.motionState, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled ? accent : ColorConstants.pureSurface, + shape: BoxShape.circle, + border: Border.all(color: accent, width: 2), + ), + child: filled + ? (accepted && !selected + // The bag number survives being accepted. It is what the rider + // matches against the shelf, and a tick here would claim a + // collection that has not happened. + ? Text( + '$ordinal', + style: MilerType.figure(11, color: ColorConstants.onAccent), + ) + : Icon( + rejected ? LucideIcons.x : LucideIcons.check, + size: 10.sp, + color: ColorConstants.onAccent, + )) + // ── The steps are numbered ── + // + // They were hollow rings, which said "there is a stop here" and + // nothing else — so a rider counting five bags off a shelf had to + // count the *rows* to know which one he was on. The manifest is an + // ordered list and the timeline should read like one: 1, 2, 3, matching + // the order the counter hands them over in. + // + // Only while there is something to number. A settled or declined step + // keeps its glyph, because at that point the position is history and + // the outcome is the fact. + : (!rejected && !skipped) + ? Text('$ordinal', style: MilerType.figure(11, color: accent)) + : skipped + ? Icon( + LucideIcons.circleAlert, + size: 9.sp, + color: ColorConstants.warning, + ) + : live + // A core rather than a glyph: the node the rider is *on* is the one + // place a solid centre reads instantly at a glance, and a tick here + // would claim work that is not finished. + ? Container( + width: size / 2.6, + height: size / 2.6, + decoration: BoxDecoration(color: accent, shape: BoxShape.circle), + ) + : null, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────── +// Small parts +// ───────────────────────────────────────────────────────────────────────── + +/// The journey cluster: how far, and how long. +/// +/// ``` +/// ➤ 5.2 km +/// ◷ ~15 min +/// ``` +/// +/// ── Why it is a block and not two right-aligned lines ── +/// +/// Right-aligning the two figures lines up their *right* edges, which is the +/// one edge nobody scans: `5.2 km` and `~15 min` end in different words, so the +/// numbers themselves — the part being compared down a multi-kitchen route — +/// landed at a different x on every row. +/// +/// This is a fixed-width block, right-aligned as a whole, with its contents +/// aligned **left inside it**. So the glyphs form one column, the digits form +/// another, and "which of these three kitchens is nearest" is answered by +/// reading straight down rather than by comparing three ragged strings. +/// +/// ── The icons ── +/// +/// Two, and they are the only icons on the node. A distance and a duration are +/// both bare numbers with a unit; at a glance, in sun, `5.2` and `~15` are the +/// same shape. The glyph is what says *which question this number answers* +/// before the eye has read the unit — which is the whole job, since the rider +/// is deciding whether to set off now. +/// +/// They sit in the caption tone, never the brand: an icon that anchors is not +/// an icon that should be looked at. +/// +/// ── Two facts, and only what is measured ── +/// +/// Either half may be absent and the cluster degrades to the other one rather +/// than printing a dash or a guess: no fix means no distance, and no distance +/// with no leg estimate means no ETA. Both absent and it is not built at all, +/// so the name takes the full width. A rider planning around a fabricated +/// `~5 min` is worse off than one who can see the app does not know. +class _JourneyColumn extends StatelessWidget { + final String? distance; + final String? ride; + + /// The group the rider is riding to next takes the larger figures. The rest + /// state the same facts one step quieter — see the note on the name. + final bool prominent; + + const _JourneyColumn({ + required this.distance, + required this.ride, + required this.prominent, + }); + + /// Fixed, so the cluster is the same width on every node and the columns line + /// up down the run. A block that resized per row would defeat the alignment it + /// exists for. + /// + /// **76, not 84.** It spans both lines of the header, so every point it takes + /// is taken twice — once from the destination and once from + /// `5 orders · 5 bags`, which was ellipsising to `5 orders · 5 ba…`. 76 fits + /// the common pair (`5.2 km` / `~15 min`) with air to spare; the rare + /// `~1h 05m` ellipsises, which is the correct thing to sacrifice, because an + /// hour-long leg is one the rider reads once and not one he compares. + /// Back to 76 after a shave to 68, which was ~3pt short of `3.1 km` at 17sp + /// and rendered the headline metric as `3.1 …`. The block cannot be squeezed + /// to buy width for the left column; the left column has to stop asking. + static double get width => 76.w; + + @override + Widget build(BuildContext context) { + if (distance == null && ride == null) return const SizedBox.shrink(); + + return SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (distance != null) + _line( + icon: LucideIcons.navigation, + value: distance!, + style: MilerType.figure( + prominent ? 17 : 15, + color: ColorConstants.slateText, + ), + ), + if (distance != null && ride != null) SizedBox(height: 4.h), + if (ride != null) + _line( + icon: LucideIcons.clock, + value: ride!, + // The ride time qualifies the distance rather than competing with + // it: same column, one step down in size and weight. + style: MilerType.label.copyWith( + fontSize: prominent ? 13.sp : 12.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + ), + ), + ], + ), + ); + } + + Widget _line({ + required IconData icon, + required String value, + required TextStyle style, + }) { + return Row( + children: [ + Icon(icon, size: 13.sp, color: ColorConstants.secondaryText), + SizedBox(width: 6.w), + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ), + ), + ], + ); + } +} + +/// The state, as a word on the load line. +/// +/// A pill costs its text **plus 18pt of horizontal padding**, and on a two-line +/// node every one of those points comes out of either the destination or the +/// bag count. Colour and weight carry a five-letter state perfectly well without +/// a capsule around it — the capsule was buying separation from a line that is +/// already separated by a `·`. +/// +/// Set at the load line's own size so the two read as one statement rather than +/// as a label stuck to a fact. +class _StateWord extends StatelessWidget { + final String label; + final Color color; + + const _StateWord({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + // ── A state is a box now, not a loose word ── + // + // Bare coloured text sat on the action row beside a red Navigate disc, a + // green Call disc and a grey "i" — four coloured things, one of which was + // just... words. It read as a label that had lost its control rather than + // as a state, and at a glance the only thing separating "Pending" from + // "Accepted" was a hue on 12sp type. + // + // A tinted capsule at 10% of its own colour gives the state an edge of its + // own, so it is a *thing* on the card rather than a stray phrase. The ink + // stays at full strength — every colour used here (amber, brand, green, + // grey) clears AA against its own 10% wash — and the tint means the four + // states now differ in area as well as in hue, which is what survives + // sunlight. + // Hugs its text. The wide row hands the state an `Expanded` — deliberately, + // so it is the thing that ellipsises when the row is tight — and a bare + // `Container` in one stretches to the full slack, which turned a two-word + // state into a wash of amber halfway across the card. + // ── A word, not a capsule ── + // + // The tint was a 10% wash behind every state on every group, which on a + // three-kitchen run is three coloured pills before the rider has read a + // single word. This is operational metadata — where the work has got to — + // and metadata is set, not stamped. What carries it now is position (its + // own line, under the identity) and weight, which is enough because there + // is nothing else on that line competing with it. + // + // The one exception keeps its form: [LiveMark] is filled and breathing, + // because "this stop is running" is the single state a rider must not + // miss, and it differs in shape from everything else here rather than + // merely in hue. + return Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.label.copyWith( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} + +/// The dedicated selection target, wrapped around a node. +/// +/// ── Why selection got its own control ── +/// +/// Ticking used to be *the row's tap*, which meant the row could not also open +/// anything — so an order's address, phone and notes were reachable only through +/// a chevron, and a rider who wanted to look at a stop had to hit a 34pt glyph +/// instead of the 300pt row in front of him. The two gestures were fighting over +/// one target and details lost. +/// +/// Splitting them gives each its own: the **row** opens the sheet, the **node** +/// ticks. The node is the right home for it — it is already the thing that +/// changes shape when selected, so the control and its own state indicator are +/// the same object rather than a checkbox bolted beside one. +/// +/// The visible mark stays small (13pt for a step); the *target* is the full rail +/// width by the full row height, which clears 44pt comfortably. A rider aiming +/// at "the dot" hits it. +class _SelectableNode extends StatelessWidget { + final bool selected; + final VoidCallback onTap; + final Widget child; + + /// Whether this rail is the thing that *announces* the selection. + /// + /// On a step it is not: the tile beside it is a second, larger target for the + /// same tick, and it carries the order's own name — so two controls announced + /// "Select …" for one order and a screen reader read the route twice. The rail + /// stays tappable and goes silent; the tile speaks. + /// + /// On a flat stop the rail is the *only* selection control, because the header + /// body opens the pickup preview. There it speaks. + final bool announce; + + const _SelectableNode({ + required this.selected, + required this.onTap, + required this.child, + this.announce = true, + }); + + @override + Widget build(BuildContext context) { + final control = InkResponse( + onTap: onTap, + radius: ButtonSizes.minTapTarget / 2, + containedInkWell: false, + child: child, + ); + + if (!announce) return ExcludeSemantics(child: control); + + return Semantics( + button: true, + checked: selected, + label: selected ? 'Deselect this order' : 'Select this order', + excludeSemantics: true, + child: control, + ); + } +} + +/// A 48pt circular utility on the card: call the place, or open its details. +/// +/// Tonal, never filled. The filled slot on this card belongs to the journey +/// button inside the dropdown, and a screen with two filled controls has no +/// primary action. The tint carries the meaning instead — green for a call, +/// because that is what every phone on earth puts on one and it is the single +/// piece of iconography a rider never has to learn; slate for details, because +/// reading about a place is not an action with a colour. +class _CircleAction extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final VoidCallback onTap; + + /// Solid rather than tonal — the one primary among the three. + final bool filled; + + const _CircleAction({ + required this.icon, + required this.color, + required this.label, + required this.onTap, + this.filled = false, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: label, + excludeSemantics: true, + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + // ── 48 of target, 44 of disc ── + // + // The painted circle shrank inside a target that did not. A 48pt disc + // puts 14pt of tint either side of a 20pt glyph, so even flush against + // the card the *icon* sat ~24pt off the edge and the corner read as + // empty. 44 is the app's tap floor and still comfortably hittable — + // the surrounding `InkWell` keeps the full 48 of touch area. + child: SizedBox( + width: ButtonSizes.secondary, + height: ButtonSizes.secondary, + child: Center( + child: Container( + width: ButtonSizes.minTapTarget, + height: ButtonSizes.minTapTarget, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled ? color : color.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 20.sp, + color: filled ? ColorConstants.onAccent : color, + ), + ), + ), + ), + ), + ), + ); + } +} + +/// `🗺 Map` — the run head's one action, at the far right of its rule. +/// +/// ── It was too small to find ── +/// +/// A 15pt glyph and an 11sp eyebrow inside 6pt of padding is a ~44×27 control +/// carrying letters a rider cannot read in sun without stopping. It is the only +/// way to the whole route on a map, and it looked like a caption. +/// +/// It is a proper secondary action now: a tonal pill, 13sp at w800, a 20pt map +/// glyph, and a target that clears the app's floor in both axes. Still tonal +/// rather than filled — the route below it owns the screen, and the map is where +/// the rider goes to *check* rather than to work. +class _TextAction extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _TextAction({ + required this.label, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'See the whole route on a map', + excludeSemantics: true, + // ── A text action, which is what the class is called ── + // + // It was a filled brand-tinted pill. Two lines above, its own note says + // the eyebrow and this "are not the same kind of thing — one names the + // section, one does something — so they are set differently rather than + // both shouting", and then it shouted: a tinted ground, a brand glyph + // and a brand w700 label, all for a secondary way into a screen the + // route below already navigates to stop by stop. + // + // It also spent the page's one loud colour. Home now marks the group the + // rider is riding to with a brand node, and that node has to be the + // thing the eye finds first — it cannot compete with a pill in the + // corner wearing the same red. + // + // So the ground goes and the ink stays: brand, still obviously tappable, + // no longer a block. The 44pt target is unchanged. + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 17.sp, color: ColorConstants.primary), + SizedBox(width: 6.w), + Text( + label, + style: MilerType.label.copyWith( + fontWeight: FontWeight.w600, + color: ColorConstants.primary, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/home/selection_bar.dart b/lib/views/Dashboard/home/selection_bar.dart index ac1866e..6ad69e2 100644 --- a/lib/views/Dashboard/home/selection_bar.dart +++ b/lib/views/Dashboard/home/selection_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; @@ -39,7 +40,79 @@ import 'package:miler/widget/Bottom_page.dart'; /// untick each stop by hand, which on a six-stop route is six taps to undo one /// mistake. /// ───────────────────────────────────────────────────────────────────────── +/// What the ticked stops are ready for. +/// +/// A service rider works a kitchen as a batch: he ticks the five parcels that +/// come from it, accepts them, walks in, slides *arrived*, is handed the crate, +/// slides *picked up*. One gesture per rung for the whole set rather than five +/// gestures per rung — the five bags arrived as one handover and the control +/// should match what happened. +enum SelectionAction { + /// Take these stops on. + accept, + + /// Say he has reached the source. + arrive, + + /// Say the parcels are in his box. The last rung on Home. + pickUp, +} + +extension SelectionActionX on SelectionAction { + /// The words on the control, in the rider's voice. + /// + /// "Mark as…" rather than "I've…": these two are *claims recorded against a + /// place*, and the rider is not talking to the app, he is marking a set of + /// orders. The wording also matches what the sheet behind it then asks him + /// to confirm, so the button and the sheet read as one action rather than + /// two. + String get label => switch (this) { + SelectionAction.accept => 'Accept', + SelectionAction.arrive => 'Mark as Arrived', + SelectionAction.pickUp => 'Mark as Picked', + }; + + /// The status token this rung writes. + String get status => switch (this) { + SelectionAction.accept => 'ACCEPTED', + SelectionAction.arrive => 'ARRIVED', + SelectionAction.pickUp => 'PICKED', + }; + + /// Accepting commits from the bar; the two that move real parcels go through + /// a sheet with a slide in it. + /// + /// Not decoration. "Picked" is the rider asserting he is holding somebody's + /// lunch, and it is the last thing standing between the order and a delivery + /// list — worth a deliberate gesture, and worth taking his eyes off a list of + /// cards for the second it takes. Accepting is reversible and happens in bulk + /// at the start of a shift, so it stays a tap. + /// + /// The slide used to be *on* this bar, inline. It lost: a 44dp-high track + /// squeezed between a ✕ and the screen edge is a gesture the rider misses + /// with a thumb on a moving bike, and there was no room left to say what he + /// was confirming. The bar now says what will happen and [StopActionSheet] + /// gives the gesture a whole surface — see the note there. + bool get confirmsInSheet => this != SelectionAction.accept; +} + class SelectionBar extends StatelessWidget { + /// The tallest this bar gets, excluding the device inset. + /// + /// ── Why this is declared rather than guessed ── + /// + /// Home reserved `80.h` of scroll extent to clear whatever floats over it. + /// That number was measured once, by eye, against one of the two surfaces + /// that can occupy the slot — and every later change to either (a second + /// line of copy, a taller button, a text scale the tester did not try) made + /// it wrong silently, by hiding the end of the route under the bar. + /// + /// A control that overlays a list is the only thing that knows how much of + /// the list it covers, so it says so. `ButtonSizes.primary` is the action + /// row; the rest is this widget's own vertical padding, and the two-line + /// accessibility layout is covered because the button grows with it. + static double get maxHeight => ButtonSizes.primary + 28.h; + /// How many stops are ticked. Zero renders nothing. final int count; @@ -56,6 +129,14 @@ class SelectionBar extends StatelessWidget { /// True while the accept is in flight. final bool busy; + /// Which rung the ticked stops are on. Anything past [SelectionAction.accept] + /// swaps the buttons for a slide and drops Reject — a stop already taken on + /// cannot be declined from here. + final SelectionAction action; + + /// Runs the rung named by [action]. Falls back to [onAccept] when null. + final Future Function()? onAdvance; + const SelectionBar({ super.key, required this.count, @@ -63,6 +144,8 @@ class SelectionBar extends StatelessWidget { required this.onClear, this.onReject, this.busy = false, + this.action = SelectionAction.accept, + this.onAdvance, }); @override @@ -88,86 +171,140 @@ class SelectionBar extends StatelessWidget { // read that way against a list of white cards. elevation: 0, child: Container( - padding: EdgeInsets.fromLTRB(6.w, 8.h, 10.w, 8.h), + // 8 → 6. The bar is a *command surface*, not a card: what should + // read as generous is the buttons on it, not the air around them. + padding: EdgeInsets.fromLTRB(6.w, 6.h, 8.w, 6.h), + // Borderless, like every other surface on this screen: the lift is + // what says "this is over the list", and a hairline under a shadow + // only muddies the edge. decoration: BoxDecoration( color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all(color: ColorConstants.borderSubtle), - boxShadow: DesignConstants.shadowLg, + borderRadius: BorderRadius.circular(22), + boxShadow: DesignConstants.shadowFloat, ), - child: Row( - children: [ - _ClearButton(onTap: busy ? null : onClear), - SizedBox(width: 2.w), - // The running count, in words — but only when there is room for - // it. With two decisions on the bar there is not: at 320pt the - // three fixed controls plus this line overflowed by 94pt, and - // "Accept 3" already carries the number. So the sentence is a - // luxury for the one-button case and the space goes to the - // buttons otherwise. - if (onReject == null) - Expanded( - child: Text( - '$count ${count == 1 ? 'stop' : 'stops'} selected', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + child: action.confirmsInSheet + // ── The rung, as one big button ── + // + // At this point in the shift the rider is standing at a counter + // holding a crate, and there is exactly one thing to press. + // Nothing shares the bar with it: no Reject (these orders are + // already his), no running-count sentence (the count is on the + // button), no slide track squeezed to whatever width was left. + // + // It is a full-width control because it is pressed with a thumb, + // one-handed, without looking — and because the gesture that + // actually commits is one layer away, in the sheet it opens, so + // this press is safe to make easy. + // + // 56 → 48. At 56 inside an 8pt-padded bar the whole thing stood + // 72pt off the nav bar and covered the last two rows of the + // route it was acting on — a command surface must not hide its + // own subject. 48 is the same button one size down the app's + // own scale, still 20pt above the tap-target floor, and it + // matches the height Reject and Accept use on the other branch + // so the bar does not change stature between rungs. + ? Row( + children: [ + _ClearButton(onTap: busy ? null : onClear), + SizedBox(width: 6.w), + Expanded( + child: MilerButton( + // `Mark as Arrived (3)`. The count in brackets, not + // after a middot: a dotted suffix reads as a second + // fact about the action, and this is the action's + // own object — how many stops the press closes. + label: '${action.label} ($count)', + icon: action == SelectionAction.arrive + ? LucideIcons.mapPinCheck + : LucideIcons.shoppingBag, + color: action == SelectionAction.arrive + ? ColorConstants.primary + : ColorConstants.acceptGreen, + height: ButtonSizes.secondary, + loading: busy, + onPressed: busy ? null : () => onAdvance?.call(), + ), ), - ), + ], ) - else - const Spacer(), - SizedBox(width: 6.w), - // ── Both decisions, on the bar ── - // - // Reject used to live on every stop row and went when the rows - // became tick-only. That left the rider able to say yes to a - // selection and nothing else: declining a stop he could not do - // — shop shut, address wrong — had no route at all, and the hub - // learned about it by the stop simply never being accepted. - // - // So it comes back where Accept is, on the set he has picked. - // Outlined and unlabelled-by-count against Accept's filled - // green: they are not equal choices, and the one that ends work - // should not look like the one that starts it. - if (onReject != null) ...[ - Flexible( - child: MilerButton( - // No icon, and flexible: the width an icon costs is width - // the two labels need side by side on a 320pt phone, and - // under real pressure the pair should give ground evenly - // rather than run off the edge of the bar. - label: 'Reject', - variant: MilerButtonVariant.outlined, - color: ColorConstants.errorRed, - height: ButtonSizes.secondary, - expand: false, - onPressed: busy ? null : onReject, - ), + : Row( + children: [ + _ClearButton(onTap: busy ? null : onClear), + SizedBox(width: 2.w), + // The running count, in words — but only when there is + // room for it. With two decisions on the bar there is + // not: at 320pt the three fixed controls plus this line + // overflowed by 94pt, and "Accept 3" already carries the + // number. So the sentence is a luxury for the one-button + // case and the space goes to the buttons otherwise. + if (onReject == null) + Expanded( + child: Text( + '$count ${count == 1 ? 'stop' : 'stops'} selected', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ) + else + const Spacer(), + SizedBox(width: 6.w), + // ── Both decisions, on the bar ── + // + // Reject used to live on every stop row and went when the + // rows became tick-only. That left the rider able to say + // yes to a selection and nothing else: declining a stop he + // could not do — shop shut, address wrong — had no route + // at all, and the hub learned about it by the stop simply + // never being accepted. + // + // So it comes back where Accept is, on the set he has + // picked. Outlined against Accept's filled green: they are + // not equal choices, and the one that ends work should not + // look like the one that starts it. + if (onReject != null) ...[ + // Capped, not flexed. [MilerButton] fills whatever box + // it is given, so these are the widths the two labels + // actually need — anything larger and the pair overflows + // the bar on a 390pt phone, anything flexible and the + // Spacer steals half. + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 104.w), + child: MilerButton( + label: 'Reject', + variant: MilerButtonVariant.outlined, + color: ColorConstants.errorRed, + height: ButtonSizes.secondary, + expand: false, + onPressed: busy ? null : onReject, + ), + ), + SizedBox(width: 8.w), + ], + // Content-hugging, so the count on the left keeps its room + // on a narrow phone and the label never ellipsises — a + // bulk action reading "Accept 3 sto…" is not one the rider + // will trust. + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 142.w), + child: MilerButton( + label: 'Accept ($count)', + icon: LucideIcons.checkCheck, + color: ColorConstants.acceptGreen, + height: ButtonSizes.secondary, + expand: false, + loading: busy, + onPressed: onAccept, + ), + ), + ], ), - SizedBox(width: 8.w), - ], - // Content-hugging, so the count on the left keeps its room on a - // narrow phone and the label never ellipsises — a bulk action - // that reads "Accept 3 sto…" is not one the rider will trust. - Flexible( - child: MilerButton( - label: 'Accept $count', - icon: Icons.done_all_rounded, - color: ColorConstants.acceptGreen, - height: ButtonSizes.secondary, - expand: false, - loading: busy, - onPressed: onAccept, - ), - ), - ], - ), ), ), ), @@ -191,7 +328,7 @@ class _ClearButton extends StatelessWidget { width: ButtonSizes.minTapTarget, height: ButtonSizes.minTapTarget, child: Icon( - Icons.close_rounded, + LucideIcons.x, size: 20.sp, color: ColorConstants.secondaryText, ), diff --git a/lib/views/Dashboard/home/stop_action_sheet.dart b/lib/views/Dashboard/home/stop_action_sheet.dart new file mode 100644 index 0000000..c7a9c34 --- /dev/null +++ b/lib/views/Dashboard/home/stop_action_sheet.dart @@ -0,0 +1,815 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:slide_to_submit_button/slide_to_submit_button.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RUNG SHEET — one deliberate gesture, at the place it describes. +/// +/// ``` +/// ┌─────────────────────────────────┐ +/// │ ARRIVED AT VIDHYA KITCHEN │ +/// │ 3 orders to collect here │ +/// │ │ +/// │ ⟶ Slide to update │ +/// └─────────────────────────────────┘ +/// ``` +/// +/// ── Why a sheet and not a button on the row ── +/// +/// Arriving and collecting are claims about the physical world: *I am at this +/// counter*, *this bag is in my box*. They are written to the hub, they move +/// money and food, and a stray thumb on a moving bike should not be able to +/// make them. The rest of this app already reserves the slide gesture for +/// exactly that class of statement, and a sheet is what gives the slide room +/// and takes the rider's attention off the list for the second it takes. +/// +/// The batch ladder on the selection bar does the same job for a set the rider +/// ticked. This is the single-stop path: he is standing at one counter with one +/// card open, and making him tick it first to reach a control that then acts on +/// "the selection" is a detour around what he is looking at. +/// +/// ── What it says ── +/// +/// The heading names the **place**, not the status. "Arrived at Vidhya Kitchen" +/// tells him what he is confirming; "Update status" tells him nothing he could +/// be wrong about. Under it, the count of what that confirmation covers. +/// ───────────────────────────────────────────────────────────────────────── +/// What the rider actually confirmed, once the slide lands. +/// +/// A rung used to be a yes/no: he slid, and every order in the batch moved. It +/// is not, at a kitchen hatch — the counter hands over four bags out of five +/// often enough that the app needs somewhere to say so, and the alternative was +/// a *second* bulk flow with its own tick-list living beside this one. The two +/// have been folded together: this sheet is the only place a pickup is +/// confirmed, and this is what it reports back. +class RungOutcome { + /// Orders the counter could not supply. They do **not** move up the ladder — + /// they leave the route with their own record. See [MilerApi] callers. + final List missingOrderIds; + + /// One photo of the whole load, if he took one. Never per bag: a rider at a + /// hatch with fifteen boxes cannot photograph each one, and the question + /// anybody asks afterwards is "what was in the crate when it left?". + final String? photoPath; + + const RungOutcome({this.missingOrderIds = const [], this.photoPath}); + + bool get isClean => missingOrderIds.isEmpty; +} + +class StopActionSheet extends StatefulWidget { + /// The rung being confirmed. Only [StopStatus.accepted] (→ arrived) and + /// [StopStatus.arrived] (→ picked) open this sheet. + final StopStatus stage; + + /// Where he is — the kitchen for a pickup rung, the customer for a drop. + final String placeName; + + /// How many orders this confirmation covers at that place. + final int count; + + /// The orders this covers, each with the bag it travels in. + /// + /// ── Why the sheet lists them ── + /// + /// This is the moment the rider is standing at a counter with bags in front + /// of him, and the only question that matters is *are these the right ones, + /// and are they all here*. A number alone cannot be checked against a shelf; + /// five named lines with a bag each can, and a missing sixth is visible + /// before he rides away rather than at a door twenty minutes later. + /// + /// Empty renders the sheet as it was — a heading, a count and the slide — + /// which is correct for a rung that covers a single anonymous stop. + final List lines; + + /// Runs the write, given what the rider actually confirmed. Returns null on + /// success, or a message to show without closing — the sheet stays open so he + /// can retry from where he is. + final Future Function(RungOutcome) onConfirm; + + const StopActionSheet({ + super.key, + required this.stage, + required this.placeName, + required this.count, + required this.onConfirm, + this.lines = const [], + }); + + /// Opens the sheet and answers true once the rung was written. + static Future show( + BuildContext context, { + required StopStatus stage, + required String placeName, + required int count, + required Future Function(RungOutcome) onConfirm, + List lines = const [], + }) async { + // Through the kit — which also gives this sheet the app's entrance curve. + // It was the one sheet still on Flutter's default 250ms decelerate: the + // most consequential confirmation in the app arrived with a pop while + // everything around it rose. + final done = await showMilerSheet( + context, + builder: (_) => StopActionSheet( + stage: stage, + placeName: placeName, + count: count, + onConfirm: onConfirm, + lines: lines, + ), + ); + return done ?? false; + } + + @override + State createState() => _StopActionSheetState(); +} + +class _StopActionSheetState extends State { + bool _busy = false; + String? _error; + + /// Orders the counter could not supply, by id. + /// + /// Empty is the overwhelmingly common case and the sheet is built for it: the + /// rider slides and leaves. Reporting a shortfall is a deliberate detour he + /// takes only when the shelf disagrees with the list — see [_reporting]. + final Set _missing = {}; + + /// True once he has opened the shortfall list. The manifest is read-only + /// until then: a tappable row on a screen he is only meant to *read* is a + /// row he will mark by accident with a thumb on a moving bike. + bool _reporting = false; + + /// One photo of the whole load. Null until he takes it, and optional always. + String? _photoPath; + bool _takingPhoto = false; + + /// How many are actually coming with him. + int get _loaded => widget.count - _missing.length; + + /// An empty crate is not a collection. + /// + /// Marking every bag missing means the counter handed over nothing, and that + /// is a wasted trip and a call to the hub — not a state to file silently. The + /// slide goes dead and says so, which is the rule the collect sheet this + /// replaced enforced with a disabled button. + bool get _canSlide => _isArrival || _loaded > 0; + + /// Whether the shortfall controls apply at all. Arrival is a claim about a + /// place — he is either there or he is not — and nothing is handed over, so + /// there is nothing to be short of. + bool get _canReportMissing => !_isArrival && widget.lines.isNotEmpty; + + bool get _isArrival => widget.stage == StopStatus.accepted; + + /// Names the place, because that is the thing he can be wrong about. + String get _title { + final place = widget.placeName.trim(); + if (_isArrival) { + return place.isEmpty ? 'Confirm arrival' : 'Arrived at $place'; + } + return place.isEmpty ? 'Confirm pickup' : 'Picked up from $place'; + } + + /// `5 orders · 5 bags` — the rule, stated where he can check it against the + /// shelf. See [BagManifest]. Once he reports a shortfall it counts what he is + /// actually leaving with, and names the gap rather than quietly shrinking. + String get _subtitle => _missing.isEmpty + ? BagManifest.countLabel(widget.count) + : '${BagManifest.countLabel(_loaded)} · ${_missing.length} missing'; + + /// What the slide will actually change, in the rider's words. "Slide to + /// update" said nothing a rider could be wrong about; these name the claim — + /// including the one where nothing was handed over at all. + String get _slideLabel { + if (_isArrival) return 'Slide to confirm arrival'; + if (!_canSlide) return 'Nothing to collect'; + return 'Slide to confirm pickup'; + } + + /// The one instruction that belongs on a pickup: count the bags first. + /// + /// ── Why it carries its own severity ── + /// + /// Three different sentences come out of here and only two of them are + /// exceptions. All three were painted amber, so the standing "count your + /// bags" line — which shows on *every* clean pickup — arrived in the ink the + /// app reserves for something genuinely wrong. A warning a rider sees every + /// single time is one he stops seeing, and the two lines that really do need + /// him had nothing left to say it with. + /// + /// So each returns whether it is an exception, and the colour follows that + /// rather than the slot. + (String, bool)? get _instruction { + if (_isArrival || widget.lines.isEmpty) return null; + if (!_canSlide) { + return ('Nothing was handed over — call the hub before you leave.', true); + } + if (_missing.isEmpty) { + return ( + 'Make sure all ${widget.count} bags are loaded before continuing.', + false, + ); + } + return ( + '${_missing.length} of ${widget.count} not supplied — they will leave ' + 'your route.', + true, + ); + } + + Color get _accent => + _isArrival ? ColorConstants.primary : ColorConstants.acceptGreen; + + /// Drives the confirmation without the drag, for tests that pin the + /// *payload* rather than the gesture. Named so it cannot be mistaken for a + /// production entry point — the slide is the only way a rider reaches this. + @visibleForTesting + Future confirmForTest() => _run(); + + Future _run() async { + if (_busy || !_canSlide) return; + setState(() { + _busy = true; + _error = null; + }); + + final failure = await widget.onConfirm( + RungOutcome(missingOrderIds: _missing.toList(), photoPath: _photoPath), + ); + if (!mounted) return; + + if (failure != null) { + // Held open on purpose. The most common failure here is the geofence + // refusing because he is not close enough yet — and the answer to that is + // to walk twenty metres and slide again, which he can only do if the + // sheet is still in front of him. + setState(() { + _busy = false; + _error = failure; + }); + return; + } + Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + return MilerSheetScaffold( + padding: EdgeInsets.fromLTRB(18.w, 0, 18.w, 16.h), + // Scrolls only when it must. At normal scale this sheet fits whole; at + // a 2.0x system text a long manifest plus the photo row outgrows the + // screen by a hair, and a Column that cannot give overflows instead of + // giving the rider a way to reach the slide. + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38.w, + height: 38.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Icon( + _isArrival ? LucideIcons.mapPin : LucideIcons.shoppingBag, + size: 19.sp, + color: _accent, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + height: 1.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 3.h), + Text( + _subtitle, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + if (_instruction case (final text, final exceptional)) ...[ + SizedBox(height: 12.h), + Text( + text, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, + height: 1.35, + color: exceptional + ? ColorConstants.warning + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + if (widget.lines.isNotEmpty) ...[ + SizedBox(height: 14.h), + _manifest(), + ], + if (_canReportMissing) ...[SizedBox(height: 10.h), _reportToggle()], + // One shot of the whole load, where the load is confirmed. It was + // on a separate collect sheet; that sheet is gone and this is the + // only place a pickup is now confirmed, so the proof comes with + // it. Never blocking — see [_CratePhotoRow]. + if (!_isArrival && ServiceProfile.active.bulkLoadProof) ...[ + SizedBox(height: 10.h), + _CratePhotoRow( + path: _photoPath, + busy: _takingPhoto || _busy, + accent: _accent, + onTake: _takePhoto, + onClear: () => setState(() => _photoPath = null), + ), + ], + if (_error != null) ...[ + SizedBox(height: 14.h), + InfoBanner( + icon: LucideIcons.circleAlert, + text: _error!, + color: ColorConstants.errorRed, + ), + ], + SizedBox(height: 18.h), + _slider(), + SizedBox(height: 6.h), + ], + ), + ), + ); + } + + /// The way into reporting a shortfall, and the way back out. + /// + /// ── Why this is a detour and not the default ── + /// + /// The collect flow this replaced started with every bag *ticked* and asked + /// the rider to untick what was missing. That is the right default — the + /// crate is almost always right — but it made a checklist out of the common + /// case: ten rows of controls on a screen whose answer was "yes" nine times + /// out of ten, which is a screen riders learn to dismiss without reading. + /// + /// So the common case is now a list he reads and a slide, and the exception + /// is one tap away. The default is unchanged in meaning: nothing marked means + /// everything came. + Widget _reportToggle() { + if (!_reporting) { + return Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _busy ? null : () => setState(() => _reporting = true), + icon: Icon( + LucideIcons.triangleAlert, + size: 18.sp, + color: ColorConstants.errorRed, + ), + label: Text( + 'Report missing bag', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.errorRed, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ); + } + + return Row( + children: [ + Expanded( + child: Text( + _missing.isEmpty + ? 'Tap any bag the counter could not supply' + : '${_missing.length} marked missing', + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _reporting = false; + _missing.clear(); + }), + child: Text( + 'Cancel', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + } + + /// ── Camera, not gallery ── + /// + /// The photo is evidence of a moment: this crate, at this counter, now. + /// `ImageSource.gallery` would let a picture taken anywhere at any time stand + /// in for that, which is worse than having no photo at all — it looks like + /// proof and is not. Quality is capped because it travels over a rider's + /// mobile data and nobody is zooming in on a rice box. + Future _takePhoto() async { + if (_takingPhoto) return; + setState(() => _takingPhoto = true); + try { + final shot = await ImagePicker().pickImage( + source: ImageSource.camera, + imageQuality: 70, + maxWidth: 1280, + ); + if (!mounted) return; + if (shot != null) setState(() => _photoPath = shot.path); + } catch (e) { + debugPrint('[LOAD] Could not take crate photo: $e'); + if (mounted) { + AppFeedback.error( + context, + "Couldn't open the camera — carry on without it", + ); + } + } finally { + if (mounted) setState(() => _takingPhoto = false); + } + } + + /// The orders this rung covers, one line each: who it is for, and the bag it + /// travels in. + /// + /// Capped in height rather than paged: a kitchen handing over twenty bags is + /// rare and a sheet that grows past the fold is worse than one that scrolls. + /// It is a list, not a checklist — ticking twenty boxes at a counter is the + /// itemised load sheet's job, and this rung is one physical handover. + Widget _manifest() { + return Container( + constraints: BoxConstraints(maxHeight: 260.h), + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), + itemCount: widget.lines.length, + separatorBuilder: (_, _) => SizedBox(height: 10.h), + itemBuilder: (context, i) { + final line = widget.lines[i]; + final gone = _missing.contains(line.orderId); + + return InkWell( + onTap: _reporting && !_busy + ? () => setState(() { + gone + ? _missing.remove(line.orderId) + : _missing.add(line.orderId); + }) + : null, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Row( + children: [ + // The mark only appears while he is reporting: until then this is + // a list to read against a shelf, not a form to fill in. + if (_reporting) ...[ + Icon( + gone ? LucideIcons.circleMinus : LucideIcons.circleCheck, + size: 20.sp, + color: gone + ? ColorConstants.errorRed + : ColorConstants.borderStrong, + ), + SizedBox(width: 10.w), + ], + // The position, so a rider counting down a shelf can keep his + // place without re-reading the names. + SizedBox( + width: 18.w, + child: Text( + '${i + 1}', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + line.customer.isEmpty + ? 'Order ${line.orderId}' + : line.customer, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: gone + ? ColorConstants.secondaryText + : ColorConstants.slateText, + decoration: gone ? TextDecoration.lineThrough : null, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 10.w), + // The same tag the route rows wear — one language for one + // fact, from Home to the sheet it opens. White stands on the + // manifest's tinted panel; a missing bag keeps the error ink + // in the same body so the exception is the colour, not the + // shape. + Container( + padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h), + decoration: BoxDecoration( + color: gone + ? ColorConstants.errorRed.withValues(alpha: 0.08) + : MilerSurface.working, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Text( + gone ? 'Missing' : line.bag, + maxLines: 1, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + color: gone + ? ColorConstants.errorRed + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _slider() { + // The slide control is a fixed 52pt pill whose internals come from a + // package and do not reflow — at 2.0x system text its own row ran off the + // right edge. A control that physically cannot grow clamps its type + // instead, the same trade Material's fixed-height chrome makes; the label + // is supplementary ("slide to…") and the gesture itself is the + // affordance, so nothing a rider needs is lost at the cap. + return MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.4, + child: IgnorePointer( + ignoring: !_canSlide, + child: SlideToSubmit.custom( + height: ButtonSizes.primary, + sliderWidth: 46, + padding: const EdgeInsets.all(5), + backgroundDecoration: BoxDecoration( + color: _canSlide + ? _accent.withValues(alpha: 0.10) + : ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + foregroundDecoration: const BoxDecoration(color: Colors.transparent), + slider: Center( + child: Container( + height: 46, + width: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _canSlide ? _accent : ColorConstants.borderStrong, + shape: BoxShape.circle, + ), + child: _busy + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: ColorConstants.onAccent, + ), + ) + : Icon( + LucideIcons.arrowRight, + size: 20.sp, + color: ColorConstants.onAccent, + ), + ), + ), + hint: IgnorePointer( + child: Row( + children: [ + SizedBox(width: 46.0 + 10.w), + Expanded( + child: Text( + _slideLabel, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: _canSlide ? _accent : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 14.w), + ], + ), + ), + onSubmit: (controller) async { + await _run(); + // Reset only if we are still here — a successful slide has popped the + // route and the controller is gone with it. + if (mounted) { + try { + controller.reset(); + } catch (_) {} + } + }, + ), + ), + ); + } +} + +/// The crate photo control — a row, not a section. +/// +/// ── Optional on purpose ── +/// +/// A required photo is a rider standing at a counter unable to record what he +/// is holding because the camera permission was declined nine weeks ago, or the +/// hatch is dark, or the phone is at 2%. The load is the thing that must be +/// recorded; the picture is what makes a later dispute cheap. So it is offered +/// prominently and never blocks the button — which is also exactly what the app +/// this flow came from did with its bulk proof. +class _CratePhotoRow extends StatelessWidget { + final String? path; + final bool busy; + final Color accent; + final VoidCallback onTake; + final VoidCallback onClear; + + const _CratePhotoRow({ + required this.path, + required this.busy, + required this.accent, + required this.onTake, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + final taken = path != null; + + return InkWell( + onTap: busy ? null : (taken ? onClear : onTake), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), + decoration: BoxDecoration( + color: taken + ? ColorConstants.acceptGreen.withValues(alpha: 0.08) + : ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Row( + children: [ + // The shot itself once there is one: a thumbnail is the only + // confirmation that answers "did it actually capture the crate, + // or my shoe?" — a tick beside the word "Photo" does not. + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: SizedBox( + width: 38.w, + height: 38.w, + child: taken + ? Image.file(File(path!), fit: BoxFit.cover) + : DecoratedBox( + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + ), + child: Icon( + LucideIcons.camera, + size: 19.sp, + color: accent, + ), + ), + ), + ), + SizedBox(width: 11.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + taken ? 'Crate photographed' : 'Photograph the crate', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + fontFamily: FontConstants.fontFamily, + color: taken + ? ColorConstants.acceptGreen + : ColorConstants.slateText, + ), + ), + SizedBox(height: 1.h), + Text( + taken + ? 'Tap to retake' + : 'Optional · one shot for the load', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.secondaryText, + ), + ), + ], + ), + ), + SizedBox(width: 8.w), + if (busy) + SizedBox( + width: 18.w, + height: 18.w, + child: CircularProgressIndicator(strokeWidth: 2, color: accent), + ) + else + Icon( + taken ? LucideIcons.rotateCw : LucideIcons.chevronRight, + size: 20.sp, + color: ColorConstants.secondaryText, + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/home/stop_card.dart b/lib/views/Dashboard/home/stop_card.dart new file mode 100644 index 0000000..ac0e799 --- /dev/null +++ b/lib/views/Dashboard/home/stop_card.dart @@ -0,0 +1,977 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// STOP CARD — one order, drawn as the journey it is. +/// +/// ``` +/// ✓ ACCEPTED ~5 min · by 12:30 PM ( ) +/// +/// ● 🍴 PICKUP +/// │ Vidhya Kitchen +/// │ 📍 Ritham Tours & Travels 20, Gandhi nagar, Peelamedu… +/// │ Quantity 1 +/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ +/// ● 📦 DROP +/// Joe +/// 📍 12, SNS Colony, Peelamedu, Coimbatore +/// 🏢 Daily Grubs +/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ +/// 🧾 Stop 1 · #916-2024116374 [📞] [ⓘ] +/// +/// ┌───────────────────────────────────────────────────────┐ +/// │ ➤ Navigate to pickup │ +/// └───────────────────────────────────────────────────────┘ +/// ``` +/// +/// ── The three rules this card is built on ── +/// +/// 1. **No boxes.** One pane of glass ([GlassCard]) with no border, and +/// nothing bordered inside it. Everything that used to be a box — the type +/// chip, the state panel, the reserved control slots, the tinted meta +/// tags — is type and colour on plain rows now. +/// +/// 2. **The rider reads this at arm's length, in sun, on a bike.** So the +/// things he actually reads are large: the place name at 19sp, the address +/// at 14.5, the time he is working to at 13.5. Nothing on this card is +/// under 11.5. +/// +/// 3. **One state, one next action.** The card always says where the order is +/// (the chip, top left) and offers exactly one thing to press for it +/// (bottom). An accepted order's one action is *get me there* — see +/// [_primaryAction]. +/// ───────────────────────────────────────────────────────────────────────── +class StopCard extends StatelessWidget { + final Map stop; + + /// 1-based position in the route, printed in the footer beside the order id. + final int index; + + final StopState state; + + /// The pickup-side address, already shortened by the trip if the design's + /// tail-trimming is on. + final String address; + + /// Time at the door, shown in the card's header. + final Duration service; + + /// Which bag this order travels in — `Bag 3`, or the label the kitchen + /// printed. One order, one bag; the count is never a separate figure. See + /// [BagManifest]. + final String bag; + + /// True when this card sits under a heading that already names its pickup + /// location. + /// + /// It changes what the card leads with. Under a kitchen heading, five cards + /// each headlined "Vidhya Kitchen" is the same name six times on one screen — + /// the heading, then once per card — and the one thing that actually tells + /// the five apart, the customer, was demoted to a line underneath. So a + /// grouped card leads with **who it is for** and shows the address it is + /// going to, which is also how the kitchen's own manifest reads. + final bool groupedUnderSource; + + final bool selected; + + /// True while this stop's acceptance is on the wire — the tick goes dead so + /// it cannot disagree with the request in flight. + final bool busy; + + /// Opens the detail sheet. The whole card carries it, and so does the ⓘ tile. + final VoidCallback? onTap; + + /// Null hides the tick entirely — see the note in [TripCard] on which states + /// are still choosable. + final VoidCallback? onToggleSelect; + + final VoidCallback? onCall; + + /// Opens turn-by-turn navigation to whichever leg this stop is on. This is + /// the accepted card's primary action, not a tile in a corner: an accepted + /// order's whole content is "go to this kitchen". + final VoidCallback? onNavigate; + + /// Returns a rejected stop to undecided. + final VoidCallback? onUnreject; + + /// Takes the rider back into the stop he is already working. + final VoidCallback? onContinue; + + /// The next rung for a stop already under way, behind the same slide sheet + /// the selection bar uses. Only ever offered on an *arrived* stop — the rung + /// before that is reached by navigating there and ticking the counter's + /// orders. See [_primaryAction]. + final VoidCallback? onAdvance; + final String? advanceLabel; + + const StopCard({ + super.key, + required this.stop, + required this.index, + required this.state, + required this.address, + required this.service, + this.bag = '', + this.groupedUnderSource = false, + this.selected = false, + this.busy = false, + this.onTap, + this.onToggleSelect, + this.onCall, + this.onNavigate, + this.onUnreject, + this.onContinue, + this.onAdvance, + this.advanceLabel, + }); + + bool get _live => state == StopState.active; + bool get _rejected => state == StopState.rejected; + + @override + Widget build(BuildContext context) { + final kind = stopKindOf(stop); + + final source = stopSourceName(stop); + final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop') + .toString(); + final drop = (stop['dropaddress'] ?? stop['DropAddress'] ?? '') + .toString() + .trim(); + + // ── The card leads with where he is going ── + // + // Not with both ends of the journey. A meal order has two, and drawing both + // at equal weight — two avatars, two labels, two addresses joined by a + // thread — made every card a diagram of a journey rather than an answer to + // "where am I going and what do I do there". The destination takes the + // headline; the other end drops to one supporting line underneath. + // + // Before pickup the destination is the counter, because that is the address + // he is riding to. After it, the drop leads — and that card lives on the + // work tab, where [PickupCard] does exactly the same thing in the other + // direction. + final twoLeg = ServiceProfile.active.deliversToCustomer && drop.isNotEmpty; + final grouped = groupedUnderSource && source.isNotEmpty; + final headline = (twoLeg && !grouped && source.isNotEmpty) + ? source + : customer; + final headlineAddress = grouped && drop.isNotEmpty ? drop : address; + + final action = _primaryAction(); + + return GlassCard( + margin: EdgeInsets.only(bottom: 12.h), + onTap: onTap, + opacity: _rejected ? 0.6 : 1, + tint: _live ? ColorConstants.glassCardLive : null, + child: Padding( + // One inset for the whole card, and the button spans it. The card used + // to run four different paddings — a header inset, a leg inset, a + // footer inset and a CTA inset — which is what made the left edge of + // the content wander down the card. + padding: EdgeInsets.fromLTRB(18.w, 16.h, 18.w, 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _statusRow(kind), + SizedBox(height: 10.h), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── The headline ── + // + // The loudest thing on the card, by a clear step. It is + // what the rider shouts at a gate and what he checks a + // shop sign against, and at 19sp beside a 16sp address it + // was not winning by enough to be found without reading. + Text( + headline, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 21.sp, + fontWeight: FontWeight.w800, + height: 1.12, + letterSpacing: -0.6, + color: ColorConstants.slateText, + decoration: _rejected + ? TextDecoration.lineThrough + : null, + fontFamily: FontConstants.fontFamily, + ), + ), + if (headlineAddress.isNotEmpty) ...[ + SizedBox(height: 5.h), + Text( + headlineAddress, + maxLines: kFullAddressTrial ? 3 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + height: 1.4, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ), + ), + // Ringing ahead is the one thing a rider does from a list that + // is not "go there", so it keeps a place on the card — a single + // quiet glyph, not one of a row of tiles. + if (onCall != null) ...[ + SizedBox(width: 10.w), + _IconAction( + icon: LucideIcons.phone, + semanticLabel: 'Call this stop', + onTap: onCall!, + ), + ], + ], + ), + if (twoLeg && !grouped) ...[ + SizedBox(height: 12.h), + _dropLine(customer), + ], + SizedBox(height: 12.h), + _factsRow( + kind, + bagShownAbove: twoLeg && !grouped && bag.isNotEmpty, + ), + if (action != null) ...[SizedBox(height: 16.h), action], + ], + ), + ), + ); + } + + /// A quantity of at least one: a stop that carries nothing is not a stop, and + /// a payload that forgot to say so should not print "0". + int _qty(int v) => v > 0 ? v : 1; + + /// The type, the state and the tick, on one line above the name. + /// + /// All three are *marks*: small, quiet, and never competing with the address + /// underneath. The state chip is the only one that carries colour, because it + /// is the only one that changes. + Widget _statusRow(StopKind kind) { + final chip = _live + ? const LiveMark() + : (state == StopState.pending && + !ServiceProfile.active.handsOffAtCollection) + ? null + : StopStateChip(state: state, filled: true); + + return Row( + children: [ + Text( + kind.chipLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + // A mark, in the type's own colour — never a fill and never a + // border. See `stop_card_neutral_test`, which pins exactly that. + color: kind.chipColor, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 10.w), + if (chip != null) Flexible(child: chip), + const Spacer(), + if (onToggleSelect != null) + SelectBox( + selected: selected, + accent: ColorConstants.primary, + label: 'Select stop $index', + circular: true, + // Dead while this stop's acceptance is on the wire: un-ticking it + // there would leave the tick and the request disagreeing. + onTap: busy ? null : onToggleSelect, + ), + ], + ); + } + + /// `→ Joe · Bag 1` — the far end, in one line. + /// + /// Everything the second leg used to spend a whole block on: who it is for, + /// and which bag it is. The address is one tap away in the detail sheet, + /// where a rider going to a *kitchen* has no use for it yet. + Widget _dropLine(String customer) { + return Row( + children: [ + Icon( + LucideIcons.arrowRight, + size: 16.sp, + color: ColorConstants.deliveryChip, + ), + SizedBox(width: 7.w), + Flexible( + child: Text( + customer, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (bag.isNotEmpty) ...[SizedBox(width: 10.w), _BagChip(label: bag)], + ], + ); + } + + /// The figures, on one line: time at the door, when it is due, what is in it. + /// + /// This is the line a rider re-reads at a red light, so nothing on it is + /// under 14sp and none of it is in a box. + Widget _factsRow(StopKind kind, {required bool bagShownAbove}) { + final due = DateTime.tryParse( + (stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(), + ); + final cash = stopCollectionAmount(stop); + + final parts = [ + if (service > Duration.zero) '~${formatTripDuration(service)}', + if (due != null) 'by ${RouteMetricsHelper.formatClock(due)}', + // Position in the route. The trip's own index, which does not renumber + // itself as stops leave the screen. + 'Stop $index', + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.clock, + size: 16.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 7.w), + Expanded( + child: Text( + parts.join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + // What is in it, and which bag it is. The bag appears here whenever the + // drop line above is not already carrying it — a card must never be + // silent about which box this order is, and it must never say it twice. + Builder( + builder: (_) { + final label = bag.isNotEmpty ? bag : stopBagLabel(stop); + final meta = _MetaLine( + deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0, + collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0, + cash: cash, + muted: _rejected || state == StopState.done, + // On a meal run the parcel counts are the one-bag rule restated + // as an item count, which is exactly the second number this app + // does not allow. Only the money survives. + countsHidden: ServiceProfile.active.deliversToCustomer, + ); + final showBag = !bagShownAbove && label.isNotEmpty; + if (!showBag && meta.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: Row( + children: [ + if (!meta.isEmpty) Flexible(child: meta), + if (showBag) ...[ + if (!meta.isEmpty) SizedBox(width: 10.w), + _BagChip(label: label), + ], + ], + ), + ); + }, + ), + ], + ); + } + + /// The one thing to press on this card, sized like it matters. + /// + /// ── Why an accepted order's action is "navigate" ── + /// + /// Accepting is a promise; the next physical act is riding to the counter. + /// The rung after that — *I have arrived* — is a claim about a place, and it + /// is made from the selection bar with every order from that counter ticked, + /// because he arrives at a kitchen, not at an order. So the card offers the + /// journey and the bar offers the claim, and neither duplicates the other. + /// + /// A stop that is already *arrived* is the one exception: the single-order + /// case ("one bag, one counter") would otherwise make him tick a card he is + /// looking at to reach a control that talks about "the selection". That + /// button opens the same slide sheet the bar does, over the same batch. + Widget? _primaryAction() { + Widget wrap(Widget child) => Padding( + padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 16.h), + child: SizedBox(width: double.infinity, child: child), + ); + + if (_rejected) { + return onUnreject == null + ? null + : wrap( + MilerButton( + label: 'Undo reject', + icon: LucideIcons.undo2, + variant: MilerButtonVariant.outlined, + color: ColorConstants.slateText, + height: ButtonSizes.secondary, + loading: busy, + onPressed: busy ? null : onUnreject, + ), + ); + } + + // The stop under way, on a parcel route: back into the verification flow. + if (_live && onContinue != null) { + return wrap( + MilerButton( + label: stopKindOf(stop).isDelivery + ? 'Continue delivery' + : 'Continue pickup', + icon: LucideIcons.arrowRight, + color: ColorConstants.primary, + height: ButtonSizes.primary, + onPressed: onContinue, + ), + ); + } + + // The stop under way, on a service route: the next rung, over the batch at + // this counter. + if (_live && onAdvance != null && advanceLabel != null) { + return wrap( + MilerButton( + label: advanceLabel!, + icon: LucideIcons.shoppingBag, + color: ColorConstants.acceptGreen, + height: ButtonSizes.primary, + loading: busy, + onPressed: busy ? null : onAdvance, + ), + ); + } + + // Under way, with nothing on this screen able to move it on: say where it + // *is* worked. A sentence, not a button — a control that cannot be pressed + // is not a status, and this is the one case where the card has nothing to + // offer but wayfinding. + if (_live) { + return Padding( + padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 16.h), + child: Row( + children: [ + Icon(LucideIcons.bike, size: 17.sp, color: ColorConstants.primary), + SizedBox(width: 8.w), + Flexible( + child: Text( + 'In progress — finish it in ' + '${ServiceProfile.active.workTabLabel}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + // Taken on, and not yet there. + if (state == StopState.accepted && onNavigate != null) { + return wrap( + MilerButton( + label: 'Navigate to pickup', + icon: LucideIcons.navigation, + // Maroon, not green: across this app green *advances the job* and + // brand red is navigation and identity. Setting off changes nothing + // at the hub, so it must not wear the colour that does. + color: ColorConstants.primary, + height: ButtonSizes.primary, + onPressed: onNavigate, + ), + ); + } + + return null; + } +} + +/// A quiet round glyph button — the card's only icon control. +/// +/// One, not a row of three. The footer used to carry call, navigate and +/// details as equal tiles; navigating is the card's primary button now and the +/// details are the card's own tap, which leaves exactly one thing worth a +/// glyph. +class _IconAction extends StatelessWidget { + final IconData icon; + final String semanticLabel; + final VoidCallback onTap; + + const _IconAction({ + required this.icon, + required this.semanticLabel, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: semanticLabel, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 48.w, + height: 48.w, + child: Center( + child: Container( + width: 42.w, + height: 42.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.primary.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 20.sp, color: ColorConstants.primary), + ), + ), + ), + ), + ); + } +} + +/// `Bag 3` — the one code the rider matches against a box in his hand. +/// +/// A neutral chip, not a coloured one: it is an identifier, not a state, and +/// the card has exactly one colour to spend. It is a chip rather than plain +/// text because it is read *against an object* — a bordered word is findable on +/// a card at arm's length in a way a run of grey text is not. +class _BagChip extends StatelessWidget { + final String label; + const _BagChip({required this.label}); + + /// Backend labels are codes — `DG-1042`, `BAG-1` — and derived ones already + /// read `Bag 3`. The word belongs on both, and the test for "already has it" + /// is the *word*, not the prefix: `BAG-1` starts with those three letters and + /// is still a code, so it becomes `Bag BAG-1` exactly as it always read. + String get _text => + label.toLowerCase().startsWith('bag ') ? label : 'Bag $label'; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Text( + _text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + } +} + +/// Tick box for selecting a stop. +/// +/// Square on the select-all strip, circular on a card — a card's tick sits +/// beside a round avatar and a round dot, and a square there was the only +/// corner in that column. +class SelectBox extends StatelessWidget { + final bool selected; + final bool partial; + final Color accent; + final String label; + final bool circular; + + /// Null while the stop's acceptance is in flight. + final VoidCallback? onTap; + + const SelectBox({ + super.key, + required this.selected, + required this.accent, + required this.label, + required this.onTap, + this.partial = false, + this.circular = false, + }); + + @override + Widget build(BuildContext context) { + final on = selected || partial; + return Semantics( + checked: selected, + label: label, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + // 48dp hit area around a 28px box: the visible control stays compact + // but the target clears the accessibility floor comfortably. This is + // the control the rider hits most on this screen, one-handed, in + // motion — it is worth the four extra points over the 44 floor. + child: Container( + width: 48.w, + height: 48.w, + alignment: Alignment.center, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 28.w, + height: 28.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? accent : ColorConstants.pureSurface, + shape: circular ? BoxShape.circle : BoxShape.rectangle, + borderRadius: circular + ? null + : BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all( + color: on ? accent : ColorConstants.borderStrong, + width: 2, + ), + ), + child: on + ? Icon( + partial ? LucideIcons.minus : LucideIcons.check, + size: 18.sp, + color: Colors.white, + ) + : null, + ), + ), + ), + ); + } +} + +/// `● Active` — the stop the rider is on, with a dot that breathes. +/// +/// The word was `LIVE`, which is broadcast vocabulary: it says a stream is +/// on air, not that a rider is standing at a counter. **Active** is the word +/// the rest of the app already uses for this rung — the trip tab's status +/// line, the queue's own heading, `StopStatus.active` itself — so the one +/// state the rider must not miss now has one name everywhere he meets it. +/// +/// Movement is what makes "this one is running right now" read without being +/// read; a static dot beside the word is a claim the interface does not back +/// up. +/// +/// The controller is created on first *use* rather than on the declaration, so +/// a `dispose()` on a card that never painted cannot construct a ticker during +/// teardown — the same trap the banner hit, documented in `homepage_banner.dart`. +class LiveMark extends StatefulWidget { + const LiveMark({super.key}); + + @override + State createState() => _LiveMarkState(); +} + +class _LiveMarkState extends State + with SingleTickerProviderStateMixin { + AnimationController? _controller; + + AnimationController get _pulse => _controller ??= AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(reverse: true); + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final accent = ColorConstants.primary; + + return Container( + padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _pulse, + builder: (context, _) { + // A halo that grows and fades around a dot that stays put: + // scaling the dot itself would shift the word beside it every + // frame. + return SizedBox( + width: 8.w, + height: 8.w, + child: Stack( + alignment: Alignment.center, + children: [ + Transform.scale( + scale: 1.0 + 1.1 * _pulse.value, + child: DecoratedBox( + decoration: BoxDecoration( + color: accent.withValues( + alpha: 0.55 * (1 - _pulse.value), + ), + shape: BoxShape.circle, + ), + child: SizedBox(width: 8.w, height: 8.w), + ), + ), + Container( + width: 7.w, + height: 7.w, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + }, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + 'Active', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } +} + +/// Where this order stands, named in one word. +/// +/// [filled] gives it the tinted pill it wears at the top of a card, where it is +/// the first thing read; inline uses — anywhere it is one fact among others — +/// keep the bare icon-and-word, because a pill in a paragraph is a box. +class StopStateChip extends StatelessWidget { + final StopState state; + final bool filled; + + const StopStateChip({super.key, required this.state, this.filled = false}); + + ({Color color, IconData icon}) get _look => switch (state) { + StopState.accepted => ( + color: ColorConstants.acceptGreen, + icon: LucideIcons.circleCheck, + ), + // In his hands. The service accent rather than green: green is the app's + // "done" colour everywhere else, and a collected order is the opposite of + // done — it is the point at which he owes somebody a delivery. + StopState.collected => ( + color: ColorConstants.serviceAccent, + icon: LucideIcons.shoppingBag, + ), + StopState.active => (color: ColorConstants.primary, icon: LucideIcons.bike), + StopState.done => ( + color: ColorConstants.acceptGreen, + icon: LucideIcons.badgeCheck, + ), + StopState.skipped => ( + color: ColorConstants.warning, + icon: LucideIcons.rotateCcw, + ), + StopState.rejected => ( + color: ColorConstants.secondaryText, + icon: LucideIcons.ban, + ), + StopState.pending => ( + color: ColorConstants.secondaryText, + icon: LucideIcons.clock, + ), + }; + + @override + Widget build(BuildContext context) { + final look = _look; + final row = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(look.icon, size: filled ? 15.sp : 13.sp, color: look.color), + SizedBox(width: 6.w), + Flexible( + child: Text( + state.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: filled ? 12.5.sp : 11.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.3, + color: look.color, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + + if (!filled) return row; + return Container( + padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h), + decoration: BoxDecoration( + color: look.color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: row, + ); + } +} + +/// ── The work at this stop, on one line ── +/// +/// Plain text rather than tinted chips. The rule for a figure that has to +/// survive a glance is weight and contrast, not a container; three containers +/// on one row was the opposite of that, and on a combined stop they wrapped +/// onto a second line and made the card taller than its neighbours. +/// +/// Only the cash is coloured. Parcel counts are labels; the money is a state +/// the rider is accountable for, and green means money throughout the app. +class _MetaLine extends StatelessWidget { + final int deliver; + final int collect; + final double cash; + final bool muted; + + /// Suppresses the parcel counts, leaving only money. See the call site. + final bool countsHidden; + + const _MetaLine({ + required this.deliver, + required this.collect, + required this.cash, + this.muted = false, + this.countsHidden = false, + }); + + /// True when there is nothing to say, so the caller can skip its own spacing + /// rather than laying out a gap around a zero-height widget. + bool get isEmpty => + cash <= 0 && (countsHidden || (deliver <= 0 && collect <= 0)); + + @override + Widget build(BuildContext context) { + TextStyle style(Color c) => TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: c, + fontFamily: FontConstants.fontFamily, + ); + + final dot = TextSpan( + text: ' · ', + style: style(ColorConstants.borderStrong), + ); + + final body = muted + ? ColorConstants.secondaryText + : ColorConstants.slateText; + + final spans = []; + if (deliver > 0 && !countsHidden) { + spans.add(TextSpan(text: 'Deliver $deliver', style: style(body))); + } + if (collect > 0 && !countsHidden) { + if (spans.isNotEmpty) spans.add(dot); + spans.add(TextSpan(text: 'Collect $collect', style: style(body))); + } + if (cash > 0) { + if (spans.isNotEmpty) spans.add(dot); + spans.add( + TextSpan( + text: '₹${_trim(cash)} cash', + style: style(ColorConstants.moneyGreen), + ), + ); + } + if (spans.isEmpty) return const SizedBox.shrink(); + + return Text.rich( + TextSpan(children: spans), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } +} + +/// Rupee amounts without trailing noise: `1030`, `40`, `12.5`. +String _trim(double v) { + if (v >= 100) return v.round().toString(); + final rounded = (v * 10).round() / 10; + return rounded == rounded.roundToDouble() + ? rounded.round().toString() + : rounded.toStringAsFixed(1); +} diff --git a/lib/views/Dashboard/home/stop_detail_sheet.dart b/lib/views/Dashboard/home/stop_detail_sheet.dart index a7484ef..50b9be3 100644 --- a/lib/views/Dashboard/home/stop_detail_sheet.dart +++ b/lib/views/Dashboard/home/stop_detail_sheet.dart @@ -1,16 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:miler/views/helpers/widgets/miler_map.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/data/milk_run.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; -import 'package:miler/views/helpers/widgets/page_transitions.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart' + show milerGlassSheet; +import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -43,12 +47,23 @@ class StopDetailSheet extends StatelessWidget { final double? riderLat; final double? riderLng; + /// The bag this order travels in, as the caller's store recorded it at the + /// counter. Empty falls back to the payload's own `baglabel`, and a line + /// with neither simply has no Bag row — nothing is invented. + final String bag; + + /// Orders the rider is carrying — what turns a raw `pickup` row into the + /// delivery leg. Empty (Home's caller) leaves the raw kind in charge. + final Set collectedIds; + const StopDetailSheet({ super.key, required this.stop, required this.stopNumber, this.riderLat, this.riderLng, + this.bag = '', + this.collectedIds = const {}, }); /// Opens the sheet. Kept here so callers do not each re-declare the shape. @@ -56,22 +71,21 @@ class StopDetailSheet extends StatelessWidget { BuildContext context, { required Map stop, required int stopNumber, + String bag = '', + Set collectedIds = const {}, double? riderLat, double? riderLng, }) { - return showModalBottomSheet( - context: context, - // The tall variant: this sheet opens at 78% of the screen, so it has a - // long way to travel and gets the time to do it. - sheetAnimationStyle: kMilerLargeSheetStyle, - isScrollControlled: true, - backgroundColor: ColorConstants.pureSurface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + // The tall variant: this sheet opens at 78% of the screen, so it has a + // long way to travel and gets the time to do it. + return showMilerSheet( + context, + large: true, builder: (_) => StopDetailSheet( stop: stop, stopNumber: stopNumber, + bag: bag, + collectedIds: collectedIds, riderLat: riderLat, riderLng: riderLng, ), @@ -94,15 +108,65 @@ class StopDetailSheet extends StatelessWidget { return ''; } + /// The leg actually being worked, not the row's raw label. + /// + /// `stopKindOf` reads the payload's `type`, which on a collected milk-run + /// stop still says *pickup* — so this sheet was captioning the customer's + /// door "PICKUP LOCATION / Collect 1 parcel" while its own CTA (driven by + /// [MilkRun.navigatesToCustomer]) said "Navigate to customer". One sheet, + /// two legs. [MilkRun.workingKind] is the ladder the card reads; the sheet + /// now reads the same rung. + StopKind get _kind => MilkRun.workingKind(stop, collectedIds: collectedIds); + + /// Whether the job at this stop is the delivery leg — the one case where + /// "where is this stop" is the drop, not the collection point. + bool get _isDropLeg { + final kind = _kind; + return kind.hasDelivery && !kind.hasPickup; + } + @override Widget build(BuildContext context) { - final kind = stopKindOf(stop); - final lat = _d(stop['pickuplat'] ?? stop['PickupLat']); - final lng = _d(stop['pickuplon'] ?? stop['PickupLon']); + final kind = _kind; + + // ── The address and the pin follow the LEG ──────────────────────────── + // + // This sheet opens from two tabs. On Home every stop is a collection and + // the pickup keys are the job; on Deliveries every stop is a drop — and + // the sheet read `pickupaddress`/`pickuplat` regardless. So a rider + // opening a drop's details met **`DELIVER TO` over the kitchen's street**, + // a map pinned to the counter he had already left, and a distance line + // measuring the ride back to it. Same family as the record's Route bug, + // fixed the same way: the drop keys lead on a drop, and a payload that + // carries only one address still shows that one. + // + // (`RouteMetricsHelper.metersToStop` has the same pickup-only read and + // feeds the cards' distance column — noted in ABOUT_MILER.md's gaps + // rather than changed here, because every card and ETA reads through it.) + final drop = _isDropLeg; + double leg(List preferred, List fallback) { + final v = _d(stop[preferred[0]] ?? stop[preferred[1]]); + return v != 0 ? v : _d(stop[fallback[0]] ?? stop[fallback[1]]); + } + + final lat = drop + ? leg(const ['droplat', 'DropLat'], const ['pickuplat', 'PickupLat']) + : _d(stop['pickuplat'] ?? stop['PickupLat']); + final lng = drop + ? leg(const ['droplon', 'DropLon'], const ['pickuplon', 'PickupLon']) + : _d(stop['pickuplon'] ?? stop['PickupLon']); final hasLocation = lat != 0 && lng != 0; final name = _s(['pickupcustomer', 'tenantname']); - final address = _s(['pickupaddress', 'PickupAddress']); + final address = drop + ? _s([ + 'dropaddress', + 'DropAddress', + 'deliveryaddress', + 'pickupaddress', + 'PickupAddress', + ]) + : _s(['pickupaddress', 'PickupAddress']); final phone = _s(['pickupcontactno']); final notes = _s(['notes', 'Notes']); final otp = stopOtp(stop); @@ -110,10 +174,14 @@ class StopDetailSheet extends StatelessWidget { final collect = pickupParcelCount(stop); final cash = stopCollectionAmount(stop); - final meters = RouteMetricsHelper.metersToStop( + // Through the leg's own coordinates — `metersToStop` carries the leg flag + // now, so the sheet and the cards compute the same number from the same + // reader instead of two implementations agreeing by luck. + final double? meters = RouteMetricsHelper.metersToStop( stop, riderLat: riderLat, riderLng: riderLng, + toDrop: drop, ); return DraggableScrollableSheet( @@ -121,98 +189,105 @@ class StopDetailSheet extends StatelessWidget { minChildSize: 0.5, maxChildSize: 0.95, expand: false, - builder: (context, scrollController) => Column( - children: [ - SizedBox(height: 10.h), - Container( - width: 40.w, - height: 4.h, - decoration: BoxDecoration( - color: ColorConstants.borderStrong, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - ), - ), - Expanded( - child: ListView( - controller: scrollController, - padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h), - children: [ - _header(kind, name), - SizedBox(height: 14.h), - if (hasLocation) ...[ - _map(lat, lng, kind), - SizedBox(height: 10.h), - _distanceLine(meters), - SizedBox(height: 16.h), - ], - if (address.isNotEmpty) ...[ - _sectionLabel(kind.locationLabel), - SizedBox(height: 5.h), - // The full address, unabridged. The card above compresses it - // to two lines; this is where the rest lives. - SelectableText( - address, - style: TextStyle( - fontSize: 14.5.sp, - height: 1.45, - fontWeight: FontWeight.w600, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + // The same frosted fabric as every other sheet. This one and the + // preview were the two opaque holdouts — see the sheet kit's audit. + builder: (context, scrollController) => milerGlassSheet( + child: Column( + children: [ + const MilerSheetHandle(), + Expanded( + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h), + children: [ + _header(kind, name), + SizedBox(height: 14.h), + if (hasLocation) ...[ + _map(lat, lng, kind), + SizedBox(height: 10.h), + _distanceLine(meters), + SizedBox(height: 16.h), + ], + if (address.isNotEmpty) ...[ + _sectionLabel(kind.locationLabel), + SizedBox(height: 5.h), + // The full address, unabridged. The card above compresses it + // to two lines; this is where the rest lives. + SelectableText( + address, + style: TextStyle( + fontSize: 14.5.sp, + height: 1.45, + fontWeight: FontWeight.w500, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), ), - ), - SizedBox(height: 16.h), + SizedBox(height: 16.h), + ], + _sectionLabel('WHAT TO DO HERE'), + SizedBox(height: 8.h), + if (kind.hasDelivery) + _actionRow( + icon: LucideIcons.truck, + color: ColorConstants.deliveryAccent, + title: _countLabel('Deliver', deliver), + detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof', + ), + if (kind.hasPickup) + _actionRow( + icon: LucideIcons.package, + color: ColorConstants.pickupAccent, + title: _countLabel('Collect', collect), + detail: cash > 0 + ? 'Collect ₹${cash.round()}' + : 'Photo + weight', + ), + SizedBox(height: 6.h), + _detailGrid(), + if (notes.isNotEmpty) ...[ + SizedBox(height: 16.h), + _noteBox(notes), + ], + SizedBox(height: 20.h), + _actions(context, phone, lat, lng, hasLocation), ], - _sectionLabel('WHAT TO DO HERE'), - SizedBox(height: 8.h), - if (kind.hasDelivery) - _actionRow( - icon: Icons.local_shipping_rounded, - color: ColorConstants.deliveryAccent, - title: - 'Deliver ${deliver > 0 ? deliver : 1} parcel${deliver == 1 ? '' : 's'}', - detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof', - ), - if (kind.hasPickup) - _actionRow( - icon: Icons.inventory_2_rounded, - color: ColorConstants.pickupAccent, - title: - 'Collect ${collect > 0 ? collect : 1} parcel${collect == 1 ? '' : 's'}', - detail: cash > 0 - ? 'Collect ₹${cash.round()}' - : 'Photo + weight', - ), - SizedBox(height: 6.h), - _detailGrid(), - if (notes.isNotEmpty) ...[ - SizedBox(height: 16.h), - _noteBox(notes), - ], - SizedBox(height: 20.h), - _actions(context, phone, lat, lng, hasLocation), - ], + ), ), - ), - ], + ], + ), ), ); } + /// "Collect 1 parcel", "Deliver 3 parcels". The count that is *shown* picks + /// the word — the old inline form defaulted a zero count to the figure 1 + /// while pluralising off the raw zero, and printed "Collect 1 parcels". + static String _countLabel(String verb, int count) { + final n = count > 0 ? count : 1; + return '$verb $n parcel${n == 1 ? '' : 's'}'; + } + Widget _header(StopKind kind, String name) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Tonal, like every disc in the app — this was the last solid-filled + // badge left, a saturated coloured coin above the customer's name. Container( width: 34.w, height: 34.w, alignment: Alignment.center, - decoration: BoxDecoration(color: kind.accent, shape: BoxShape.circle), + decoration: BoxDecoration( + color: kind.accent.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), child: Text( '$stopNumber', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w800, - color: Colors.white, + fontWeight: FontWeight.w700, + color: kind.accent, fontFamily: FontConstants.fontFamily, ), ), @@ -222,35 +297,32 @@ class StopDetailSheet extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), - decoration: BoxDecoration( - color: kind.accent, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Text( - kind.badgeLabel, - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.3, - color: Colors.white, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - SizedBox(height: 6.h), + // ── The type chip is gone from the header ── + // + // A filled "MILK" / "DELIVERY" tab sat ABOVE the customer's + // name — category before subject, in a solid service colour, + // on the one surface where no card idiom shows that chip any + // more. What the stop *is* the sheet already states twice, in + // words: the WHAT TO DO HERE row and the grid's From/Bag + // lines. The name leads now, as it does on every card. Text( name.isEmpty ? 'Stop $stopNumber' : name, style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.15, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), + // ── The one fact the sheet never stated ── + // + // Everything else here is reference; where this stop IS in its + // lifecycle was only knowable from the card underneath the + // barrier. The word, glyph and colour come from the canonical + // vocabulary ([StopStatusX]) — never a switch written here. + _statusLine(), ], ), ), @@ -258,6 +330,34 @@ class StopDetailSheet extends StatelessWidget { ); } + Widget _statusLine() { + final status = stopStatusOf(stop); + if (status == StopStatus.unknown) return const SizedBox.shrink(); + return Padding( + padding: EdgeInsets.only(top: 5.h), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(status.icon, size: 14.sp, color: status.color), + SizedBox(width: 5.w), + Flexible( + child: Text( + status.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: status.color, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + /// A small, non-interactive map. Orientation only — it answers "which side of /// town is this?" in one glance and then gets out of the way. /// @@ -278,11 +378,6 @@ class StopDetailSheet extends StatelessWidget { /// is already paid for by [AfterEntrance] holding it back, so nothing is lost /// by dropping it. Widget _map(double lat, double lng, StopKind kind) { - final stopPos = LatLng(lat, lng); - final hasRider = - riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0; - final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null; - return ClipRRect( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), child: SizedBox( @@ -301,45 +396,21 @@ class StopDetailSheet extends StatelessWidget { // later, which is what Uber's own sheets do. child: AfterEntrance( placeholder: MapPlaceholder(radius: 14.r), - builder: (context) => IgnorePointer( - // Not interactive: a pannable map inside a draggable sheet fights - // the sheet's own gestures, and this is not where routing happens. - // [MilerMap] carries its own gesture set; the IgnorePointer above it - // is what actually settles the argument. - child: MilerMap( - initialCenter: stopPos, - initialZoom: 14.5, - polylines: [ - if (riderPos != null) - Polyline( - points: [riderPos, stopPos], - color: kind.accent, - strokeWidth: 4, - strokeCap: StrokeCap.round, - ), - ], - markers: [ - milerMarker( - point: stopPos, - width: MilerPin.size + 8, - height: MilerPin.size + MilerPin.tail, - // The stop's own accent, not one of nine stock hues — the same - // colour as the chip on the card this sheet opened from. - child: MilerPin( - color: kind.accent, - icon: kind.icon, - emphasised: true, - ), - ), - if (riderPos != null) - milerMarker( - point: riderPos, - width: MilerRiderDot.size, - height: MilerRiderDot.size, - child: const MilerRiderDot(), - ), - ], - ), + // The leg, drawn honestly — the real road from OSRM, both endpoints + // pinned, camera fitted to the pair. This was a solid straight line + // to a fixed-zoom frame: a rider 4 km out watched the stroke leave + // the screen with no second point anywhere on it. See [MilerLegMap]. + builder: (context) => MilerLegMap( + to: LatLng(lat, lng), + from: + (riderLat != null && + riderLng != null && + riderLat != 0 && + riderLng != 0) + ? LatLng(riderLat!, riderLng!) + : null, + accent: kind.accent, + icon: kind.icon, ), ), ), @@ -351,31 +422,43 @@ class StopDetailSheet extends StatelessWidget { final travel = RouteMetricsHelper.travelTime(meters); if (distance == '—') return const SizedBox.shrink(); + // Both figures are Flexible: the pair was rigid, and rigid text in a Row + // is a right-edge overflow waiting for a large text scale — the sheet + // sweep caught exactly that. The distance keeps priority; the ride time + // is the half that gives way. return Row( children: [ Icon( - Icons.near_me_rounded, + LucideIcons.navigation, size: 16.sp, color: ColorConstants.secondaryText, ), SizedBox(width: 6.w), - Text( - '$distance away', - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w800, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + Flexible( + child: Text( + '$distance away', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), ), ), if (travel > Duration.zero) ...[ - Text( - ' · ${RouteMetricsHelper.formatDuration(travel)} ride', - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, + Flexible( + child: Text( + ' · ${RouteMetricsHelper.formatDuration(travel)} ride', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), ), ), ], @@ -387,7 +470,7 @@ class StopDetailSheet extends StatelessWidget { text, style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.8, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -414,24 +497,57 @@ class StopDetailSheet extends StatelessWidget { child: Icon(icon, size: 17.sp, color: color), ), SizedBox(width: 10.w), + // ── The task and what it asks for, stacked ── + // + // These were side by side, with the requirement pushed hard against + // the right edge — so "OTP + photo proof" was the one line on the + // sheet cut off first at large text, and at any size it read as a + // column heading rather than as a condition of the job above it. + // + // Under the title it is plainly a qualifier, it has the full width + // to wrap into, and the row can no longer overflow. Expanded( - child: Text( - title, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w800, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - Text( - detail, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w800, - color: color, - fontFamily: FontConstants.fontFamily, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + if (detail.isNotEmpty) ...[ + SizedBox(height: 3.h), + Row( + children: [ + Icon( + LucideIcons.squareCheckBig, + size: 12.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 4.w), + Flexible( + child: Text( + detail, + maxLines: 2, + // Secondary, not the leg accent: set in link blue it + // read as something to tap, and it is a statement. + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ], + ], ), ), ], @@ -441,6 +557,15 @@ class StopDetailSheet extends StatelessWidget { Widget _detailGrid() { final rows = >[ + // Whose counter the bag came off — the fact the delivery card carries + // ("PICKED UP FROM …") and this sheet used to drop. Only on the drop + // leg: on a pickup the header IS the source context. One reader, + // [stopSourceName], as the project rule demands. + MapEntry('From', _isDropLeg ? stopSourceName(stop) : ''), + // The bag is what a rider matches against a shelf, so it outranks the + // reference. Stored pairing first ([bag], written at the counter), the + // kitchen-printed label as fallback, absent on a line with no load. + MapEntry('Bag', bag.isNotEmpty ? bag : _s(['baglabel', 'BagLabel'])), MapEntry('Order', _s(['orderid', 'OrderId'])), MapEntry('Product', _s(['description', 'productname', 'product'])), MapEntry('Weight', () { @@ -457,42 +582,124 @@ class StopDetailSheet extends StatelessWidget { if (rows.isEmpty) return const SizedBox.shrink(); - return Column( - children: [ - Divider(height: 20.h, color: ColorConstants.borderSubtle), - for (final r in rows) - Padding( - padding: EdgeInsets.only(bottom: 8.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 110.w, - child: Text( - r.key, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), + return Padding( + padding: EdgeInsets.only(top: 16.h), + child: Container( + padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 4.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.borderSubtle, width: 1.2), + ), + child: Column( + children: [ + for (var i = 0; i < rows.length; i += 2) ...[ + if (i > 0) + Container(height: 1, color: ColorConstants.borderSubtle), + // Intrinsic, so the vertical hairline takes the height of the + // taller of the two tiles. `stretch` alone asks for infinity + // inside a scrolling column. + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _factTile(rows[i])), + // A single trailing fact takes the full row rather than half + // of one — a lone tile beside empty space reads as a missing + // value, which on a sheet full of real ones is alarming. + if (i + 1 < rows.length) ...[ + Container(width: 1, color: ColorConstants.borderSubtle), + Expanded(child: _factTile(rows[i + 1])), + ], + ], ), - Expanded( - child: Text( - r.value, - style: TextStyle( - fontSize: 13.5.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], + ), + ], + // ── Rules between rows, not around every cell ── + // + // A grid drawn as boxes is a spreadsheet; drawn as one surface cut + // by hairlines it is a set of facts. The last row gets none, so + // the block closes on its own edge. + ], + ), + ), + ); + } + + /// One label-over-value fact. + /// + /// ── Why this stopped being a table ── + /// + /// It was `label ── value` rows on the sheet's own grey, with a fixed 110pt + /// label column. Two problems, both of them about reading it at a gate. The + /// values — the half a rider actually needs — sat in a ragged right column + /// and had to be found one at a time; and a long one ("SNS Colony, + /// Peelamedu") wrapped under a wide empty label, so the block grew a hole in + /// the middle of it. + /// + /// Stacked, the value is the big line and the label is the small one above + /// it. Two to a row, so the whole set is scanned in a couple of saccades + /// rather than seven. + Widget _factTile(MapEntry fact) { + return Padding( + padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + fact.key.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 9.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0.9, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, ), ), - ], + SizedBox(height: 3.h), + // ── A reference is one token, so it renders as one ── + // + // `DM-BK-BDD9EF53-35734` in a half-width tile wrapped at a hyphen — + // "DM-BK-" over "BDD9EF53-35734" — which is exactly how a reference + // gets misread down a phone to the office. Anything with no spaces + // in it is a token, not prose: it shrinks to fit its line instead + // of breaking. Real sentences keep the two-line wrap. + if (!fact.value.contains(' ') && fact.value.length > 12) + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + fact.value, + maxLines: 1, + style: TextStyle( + fontSize: 14.sp, + height: 1.25, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ) + else + Text( + fact.value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + height: 1.25, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), ); } @@ -510,7 +717,7 @@ class StopDetailSheet extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.sticky_note_2_rounded, + LucideIcons.stickyNote, size: 17.sp, color: ColorConstants.warning, ), @@ -521,7 +728,7 @@ class StopDetailSheet extends StatelessWidget { style: TextStyle( fontSize: 13.sp, height: 1.4, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onErrorContainer, fontFamily: FontConstants.fontFamily, ), @@ -545,7 +752,7 @@ class StopDetailSheet extends StatelessWidget { Expanded( child: MilerButton( label: 'Call', - icon: Icons.call_rounded, + icon: LucideIcons.phone, variant: MilerButtonVariant.outlined, color: ColorConstants.acceptGreen, onPressed: () => _dial(phone), @@ -555,11 +762,26 @@ class StopDetailSheet extends StatelessWidget { ], Expanded( flex: 2, - child: MilerButton( - label: 'Navigate', - icon: Icons.navigation_rounded, - color: ColorConstants.primary, - onPressed: hasLocation ? () => _navigate(lat, lng) : null, + child: Builder( + builder: (_) { + // ── Where this button goes depends on the stop's stage ── + // + // Before collection it opens the kitchen; after collection it + // opens the customer. The rule lives in [MilkRun] so the sheet, + // the card and the deliveries tab cannot drift apart about it — + // and it returns null rather than guessing when the stop carries + // no coordinates for the leg it is on. + final target = MilkRun.navigationTarget(stop); + final toCustomer = MilkRun.navigatesToCustomer(stop); + return MilerButton( + label: toCustomer ? 'Navigate to customer' : 'Navigate', + icon: LucideIcons.navigation, + color: ColorConstants.primary, + onPressed: target == null + ? null + : () => _navigate(target.lat, target.lng), + ); + }, ), ), ], diff --git a/lib/views/Dashboard/home/trip.dart b/lib/views/Dashboard/home/trip.dart index a1c44ba..cf583e6 100644 --- a/lib/views/Dashboard/home/trip.dart +++ b/lib/views/Dashboard/home/trip.dart @@ -2,6 +2,9 @@ import 'dart:math' as math; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/data/route_order.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/Models/stop_status.dart'; /// ───────────────────────────────────────────────────────────────────────── /// A TRIP — one slot's worth of work, as a single unit. @@ -24,6 +27,147 @@ import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; /// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested /// and safe to call from `build`. /// ───────────────────────────────────────────────────────────────────────── + +/// ───────────────────────────────────────────────────────────────────────── +/// WHICH TRIP A STOP IS ON +/// +/// A rider's day is three trips, and which one an order lands on is decided by +/// **when the work is due**, not by how many buckets happen to have filled up: +/// +/// morning → Trip 1 +/// afternoon → Trip 2 +/// evening → Trip 3 +/// +/// ── What this replaces ── +/// +/// Stops were bucketed into rolling three-hour windows aligned to the clock — +/// 09:00–12:00, 12:00–15:00, and so on — which produces up to eight buckets a +/// day, folded down to three at the end. Two consequences, both wrong on a +/// real morning: an 08:30 stop and an 11:00 stop are both *the morning run* +/// and landed on different trips, and which trip a stop appeared on depended +/// on what else was in the day. A rider with only afternoon work saw it as +/// "Trip 1", so his Trip 2 and the hub's Trip 2 were different things. +/// +/// The boundaries are fixed here rather than derived, because they are a +/// business fact about the shift and not something to infer from the data. +/// ───────────────────────────────────────────────────────────────────────── +enum DayPart { + morning('Morning'), + afternoon('Afternoon'), + evening('Evening'); + + const DayPart(this.label); + + /// What this part of the day is called, on a tab and in a sentence. + final String label; + + /// Afternoon begins at noon. + static const int afternoonFromHour = 12; + + /// Evening begins at 5pm. + static const int eveningFromHour = 17; + + /// The day part [at] falls in. + static DayPart of(DateTime at) { + if (at.hour < afternoonFromHour) return DayPart.morning; + if (at.hour < eveningFromHour) return DayPart.afternoon; + return DayPart.evening; + } + + /// The window this part covers on [day]. + static (DateTime, DateTime) windowOn(DateTime day, DayPart part) { + final midnight = DateTime(day.year, day.month, day.day); + return switch (part) { + DayPart.morning => ( + midnight, + midnight.add(const Duration(hours: afternoonFromHour)), + ), + DayPart.afternoon => ( + midnight.add(const Duration(hours: afternoonFromHour)), + midnight.add(const Duration(hours: eveningFromHour)), + ), + DayPart.evening => ( + midnight.add(const Duration(hours: eveningFromHour)), + midnight.add(const Duration(days: 1)), + ), + }; + } + + /// True when [from]–[to] is exactly one day part's window, which is how + /// [Trip.slotLabel] knows to say "Morning" instead of a clock range. + static bool spans(DateTime from, DateTime to) { + final (start, end) = windowOn(from, of(from)); + return from.isAtSameMomentAs(start) && to.isAtSameMomentAs(end); + } +} + +/// The three slots of a day, whether or not the rider has work in each. +/// +/// Trip 2 is the afternoon even on a day with no morning work — the number is +/// the day part, so the rider's "Trip 2" and the hub's are the same run. +extension TripSlots on List { + /// The day laid out as slots: index 0 is the morning, 1 the afternoon, 2 the + /// evening, and an empty slot is a part of the day with no work in it. + /// + /// Dated trips claim their own part. Anything undated — a tenant that sends + /// no times, demo data, a trip whose stops carry only addresses — falls back + /// to the first free slot, in order, because a trip that cannot be placed + /// still has to be reachable. Guessing a part from the wall clock instead + /// would quietly move such a trip from Trip 1 to Trip 2 at noon. + List get slots { + final out = List.filled(DayPart.values.length, null, growable: true); + + // ── Day parts place the trips only when they can place all of them ── + // + // A trip claims the slot its part names — that is what makes an + // afternoon-only day read as Trip 2 rather than Trip 1. It works because + // the common day is one run per part. + // + // When two trips want the *same* part it stops working, and the first + // attempt at patching it made things worse: the second trip took the first + // free slot anywhere, so on an evening with two evening runs the tabs came + // out `[2, 0]` — trip one under Trip 3, trip two under Trip 1, inverted, + // and only between 5pm and midnight. A layout that depends on the hour the + // rider opened the screen is worse than one that ignores day parts. + // + // So it is all or nothing. Every trip has its own part, or the run is laid + // out in the order it arrived — which is already sorted, and which is the + // honest answer when the data does not fit the model. + final parts = {}; + var distinct = true; + for (final t in this) { + final p = t.dayPart?.index; + if (p == null || !parts.add(p)) { + distinct = false; + break; + } + } + + if (distinct) { + for (final t in this) { + out[t.dayPart!.index] = t; + } + return out; + } + + for (var i = 0; i < length; i++) { + if (i < out.length) { + out[i] = this[i]; + } else { + out.add(this[i]); + } + } + return out; + } + + /// The trip in slot [index] (0 = morning), or null when nothing is assigned + /// to that part of the day. + Trip? tripAt(int index) { + final s = slots; + return index >= 0 && index < s.length ? s[index] : null; + } +} + class Trip { /// Stable identity for this trip (backend slot/trip id, or a derived key). final String id; @@ -82,6 +226,10 @@ class Trip { final s = slotStart; final e = slotEnd; if (s == null || e == null) return "Today's route"; + // A day part is named, not spelled out as a clock range: "Morning" is what + // the rider and the hub both call it, and "5:00 AM – 12:00 PM" says the + // same thing in seven characters more and one reading step. + if (DayPart.spans(s, e)) return DayPart.of(s).label; return '${RouteMetricsHelper.formatClock(s)} – ' '${RouteMetricsHelper.formatClock(e)}'; } @@ -167,8 +315,43 @@ class Trip { rejectedIds: rejectedIds, ).any((s) => s.needsDecision); - /// Short tab label — `Trip 1`. - static String tabLabel(int index) => 'Trip ${index + 1}'; + /// Short tab label for a **slot** — `Trip 1`. + /// + /// The slot is the day part, not a running count: slot 0 is the morning + /// whether or not the rider has morning work. See [DayPart]. + static String tabLabel(int index) => + 'Trip ${index + 1}'; // slot index is the day part's index + + /// Which part of the day this trip belongs to — **null when nothing on it is + /// dated**. + /// + /// Taken from the slot the grouping put it in. A trip the backend named with + /// its own `tripid` still gets one, from whatever window its stops fall in. + /// + /// Null matters: a trip with no times at all cannot be placed by day part, + /// and guessing one from the wall clock would move it between tabs as the + /// afternoon wore on. Those keep their position instead — see + /// [TripSlots.slots]. + DayPart? get dayPart { + final at = slotStart ?? _earliestDue; + return at == null ? null : DayPart.of(at); + } + + /// `1`, `2` or `3` — the number the rider and the hub both use. Zero when + /// this trip has no time to place it by. + int get tripNumber => (dayPart?.index ?? -1) + 1; + + DateTime? get _earliestDue { + DateTime? best; + for (final s in stops) { + final due = _parseTimestamp( + s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'], + ); + if (due == null) continue; + if (best == null || due.isBefore(best)) best = due; + } + return best; + } /// Address components every stop on this trip shares — city, state, /// pincode and so on. Cached per build by the caller. @@ -211,13 +394,27 @@ class Trip { /// Estimated ride time from the previous point to stop [index]. For index 0 /// that is the hub → stop 1 leg. - Duration travelTimeToStop(int index, {double? hubLat, double? hubLng}) { + Duration travelTimeToStop( + int index, { + double? hubLat, + double? hubLng, + // ── The rail's legs run door to door, not counter to counter ── + // + // On the Deliveries rail every stop is the delivery leg, and this walked + // the route over the PICKUP pairs — so the remaining-time estimate was a + // tour of the kitchens the rider had already left. The flag mirrors + // `RouteMetricsHelper.metersToStop(toDrop:)`: the previous stop's exit + // point and this stop's target are both the drop when the leg is a + // delivery, with the pickup pair as the payload fallback. Default false — + // Home's pickup-leg arithmetic is untouched. + bool deliveryLeg = false, + }) { if (index < 0 || index >= stops.length) return Duration.zero; final ({double lat, double lng})? from = index == 0 ? _origin(hubLat, hubLng) - : _coordsOf(stops[index - 1]); - final to = _coordsOf(stops[index]); + : _coordsOf(stops[index - 1], toDrop: deliveryLeg); + final to = _coordsOf(stops[index], toDrop: deliveryLeg); if (from == null || to == null) return Duration.zero; final meters = RouteMetricsHelper.distanceMeters( @@ -356,7 +553,10 @@ class Trip { static List groupIntoTrips( List> stops, { - int windowHours = 3, + + /// No longer used for bucketing — stops are grouped by [DayPart]. Kept so + /// existing call sites compile; passing it changes nothing. + @Deprecated('Trips are bucketed by DayPart') int windowHours = 3, double? hubLat, double? hubLng, DateTime? now, @@ -403,10 +603,14 @@ class Trip { stop['eta'], ); if (due != null) { - final blockHour = (due.hour ~/ windowHours) * windowHours; - from = DateTime(due.year, due.month, due.day, blockHour); - to = from.add(Duration(hours: windowHours)); - key = 'win:${from.toIso8601String()}'; + // Morning, afternoon or evening — see [DayPart]. Not a rolling + // window: two stops due an hour apart are on the same run, and the + // trip a stop belongs to must not depend on what else is in the day. + final part = DayPart.of(due); + final (partFrom, partTo) = DayPart.windowOn(due, part); + from = partFrom; + to = partTo; + key = 'part:${due.year}-${due.month}-${due.day}:${part.name}'; } else { from = null; to = null; @@ -436,6 +640,11 @@ class Trip { if (as == null && bs == null) return 0; if (as == null) return 1; // undated trips last if (bs == null) return -1; + // Day part first, so Trip 1 is always the morning even on a day that + // starts at two in the afternoon. Within a part, earliest first. + final pa = a.dayPart?.index ?? DayPart.values.length; + final pb = b.dayPart?.index ?? DayPart.values.length; + if (pa != pb) return pa.compareTo(pb); return as.compareTo(bs); }); @@ -489,37 +698,39 @@ class Trip { /// Puts stops in the admin's intended order. /// /// `step` is authoritative — it IS the admin's solved sequence, and the app - /// must never second-guess it. Only when `step` is absent do we fall back to - /// the booked time, then to the order the backend sent. Sorting by distance - /// would be re-optimising the route, which the rider is not allowed to do. + /// must never second-guess it. Only when no stop carries one do we fall back + /// to the booked time, then to the order the backend sent. **Distance is not + /// one of the options here**: sorting by it would be re-optimising a route + /// the hub has planned, which the rider is not allowed to do. + /// + /// The rule itself lives in [RouteOrder] — one implementation, shared with + /// the Deliveries tab, which used to keep its own and disagreed. This is the + /// pickup leg's door onto it. static List> sortStops( List> stops, - ) { - final indexed = <(int, Map)>[ - for (var i = 0; i < stops.length; i++) (i, stops[i]), - ]; + ) => orderStops(stops).$1; - indexed.sort((a, b) { - final stepA = _toInt(a.$2['step'] ?? a.$2['Step']); - final stepB = _toInt(b.$2['step'] ?? b.$2['Step']); - if (stepA > 0 && stepB > 0 && stepA != stepB) return stepA - stepB; - if (stepA > 0 && stepB <= 0) return -1; - if (stepB > 0 && stepA <= 0) return 1; - - final dueA = _parseTimestamp(a.$2['expected_pickup_time']); - final dueB = _parseTimestamp(b.$2['expected_pickup_time']); - if (dueA != null && dueB != null && dueA != dueB) { - return dueA.compareTo(dueB); - } - return a.$1 - b.$1; // stable: keep backend order - }); - - return [for (final e in indexed) e.$2]; - } + /// [sortStops], plus which rule produced the order. + static (List>, RouteOrderSource) orderStops( + List> stops, + ) => RouteOrder.sort( + stops, + bookedTimeOf: (s) => _parseTimestamp( + s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'], + ), + ); // ── helpers ──────────────────────────────────────────────────────────── - static ({double lat, double lng})? _coordsOf(Map stop) { + static ({double lat, double lng})? _coordsOf( + Map stop, { + bool toDrop = false, + }) { + if (toDrop) { + final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']); + final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']); + if (dLat != 0 && dLng != 0) return (lat: dLat, lng: dLng); + } final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']); final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']); if (lat == 0 || lng == 0) return null; @@ -540,12 +751,6 @@ class Trip { return DateTime.tryParse(v.toString().trim()); } - static int _toInt(dynamic v) { - if (v == null) return 0; - if (v is num) return v.toInt(); - return int.tryParse(v.toString().trim()) ?? 0; - } - static double _toDouble(dynamic v) { if (v == null) return 0; if (v is num) return v.toDouble(); @@ -634,6 +839,14 @@ enum StopState { /// Accepted, not yet started. Lives on the Bookings tab. accepted, + /// Loaded from its source and physically in the rider's hands. + /// + /// Service routes only. The parcel ladder has no equivalent — for a parcel, + /// collecting it from the customer *is* the job — so this state exists only + /// where accepting and carrying are two different days' work apart. See + /// [ServiceProfile.handoffAt] and the collected store. + collected, + /// The rider is physically on this stop right now. active, @@ -660,17 +873,46 @@ extension StopStateX on StopState { /// Committed to: he has taken it and owes the customer a visit. bool get isCommitted => this == StopState.accepted || + this == StopState.collected || this == StopState.active || this == StopState.skipped; - String get label => switch (this) { - StopState.pending => 'Awaiting your decision', - StopState.accepted => 'Accepted', - StopState.active => 'In progress', - StopState.done => 'Completed', - StopState.skipped => 'Skipped', - StopState.rejected => 'Rejected', - }; + /// In the rider's hands right now. + bool get isCollected => this == StopState.collected; + + /// What this state is called on a card. + /// + /// ── Two vocabularies, one state machine ── + /// + /// The Doormile wording is written for a rider who is being *asked* something: + /// "Awaiting your decision" is true because the card under it carries Accept + /// and Reject. A service rider is asked nothing — the whole assignment came as + /// one commitment — so the same state means "the hub has given you this, it is + /// waiting for you to go and collect it", and calling that a decision invites + /// him to look for a button that is not there. + /// + /// `Delivered` rather than `Completed` for the same reason: on a meal run the + /// last thing that happens at a door is a hand-over, and naming it is worth + /// more than a word that covers every stop type in the app. + String get label => ServiceProfile.active.acceptsPerStop + ? switch (this) { + StopState.pending => 'Awaiting your decision', + StopState.accepted => 'Accepted', + StopState.collected => 'Collected', + StopState.active => 'In progress', + StopState.done => 'Completed', + StopState.skipped => 'Skipped', + StopState.rejected => 'Rejected', + } + : switch (this) { + StopState.pending => 'Pending', + StopState.accepted => 'Accepted', + StopState.collected => 'Collected', + StopState.active => 'Out for delivery', + StopState.done => 'Delivered', + StopState.skipped => 'Skipped', + StopState.rejected => 'Cancelled', + }; } /// Derives a stop's state from its backend status plus the local @@ -684,6 +926,10 @@ StopState stopStateOf( Map stop, { required Set acceptedIds, required Set rejectedIds, + + /// Orders the rider is carrying. Optional so every existing parcel call site + /// is unchanged — a Doormile route never populates it. + Set collectedIds = const {}, }) { final id = (stop['orderid'] ?? '').toString(); final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase(); @@ -704,14 +950,29 @@ StopState stopStateOf( if (raw == 'active' || raw == 'arrived') return StopState.active; if (raw == 'skipped') return StopState.skipped; - // 3. Local decisions next, and they beat the server's accept/reject. + // 2b. Released for delivery — which `pickup-complete` does by itself on + // hyperlocal work, in the same call that records the collection. It says + // the consignment may be delivered, not that the rider has set off, so it + // resolves to *carrying* and never to [StopState.active]. Asked ahead of + // the local sets because it must also hold after a reinstall, when the + // collected set is empty and this row would otherwise fall through to + // `pending` — putting a bag already in his box back on Home as work to + // accept. See `MilkRun.stageOf`. + if (raw == 'outfordelivery') return StopState.collected; + + // 3. Carrying it beats every remaining local view. He has the food; no + // later tap on this screen can make that untrue, and a refetch that still + // reports `accepted` must not put the card back on Home. + if (collectedIds.contains(id)) return StopState.collected; + + // 4. Local decisions next, and they beat the server's accept/reject. // They are strictly newer: the rider just tapped, and the server view is // at best one poll behind. Without this, un-rejecting a stop would be // undone by the next refetch still reporting `rejected`. if (rejectedIds.contains(id)) return StopState.rejected; if (acceptedIds.contains(id)) return StopState.accepted; - // 4. Finally the server's own view. + // 5. Finally the server's own view. if (raw == 'rejected') return StopState.rejected; if (raw == 'accepted') return StopState.accepted; return StopState.pending; @@ -732,6 +993,41 @@ enum StopProgress { skipped, } +/// How one stop's lifecycle rung reads on the route rail. +/// +/// ── "Physically on" is a question about BOTH legs ── +/// +/// This lived inline in `_MyPickupsState._tripProgress` and tested +/// `isActive || arrived` — and both of those are **pickup-leg** rungs. It is +/// read on the Deliveries tab, where every stop is past `pickup-complete` by +/// definition, so on a milk run the rung the rider is actually standing on is +/// [StopStatus.deliveryArrived], which matched neither. The customer's door he +/// was at *that moment* drew grey — indistinguishable from the eleven he had +/// not ridden to yet — and the scooter fell through to the caller's "promote +/// the first pending one" fallback, parking it somewhere up the route behind +/// him. +/// +/// The rail's whole job is *where am I*. Answering it from one leg's +/// vocabulary, on the tab that only ever shows the other leg, is why the answer +/// was wrong all round. +/// +/// [StopStatusX.isWorkComplete] decides `done`, so the answer is line-aware: +/// `picked` ends a logistics stop and is the middle of the morning on a round. +/// +/// Pure, and out here rather than on the State, because `_MyPickupsState` +/// cannot be pumped — Get, Geolocator and a 3s poll — so a mapping left inside +/// it can only ever be verified by reading it. See `trip_progress_test.dart`. +StopProgress stopProgressFor(StopStatus status) { + if (status.isWorkComplete) return StopProgress.done; + if (status.isSkipped) return StopProgress.skipped; + if (status.isActive || + status == StopStatus.arrived || + status == StopStatus.deliveryArrived) { + return StopProgress.current; + } + return StopProgress.pending; +} + /// Formats a duration the way a rider plans: `2h 15m`, `45 min`. String formatTripDuration(Duration d) { if (d.inMinutes < 1) return '—'; diff --git a/lib/views/Dashboard/home/trip_brief_strip.dart b/lib/views/Dashboard/home/trip_brief_strip.dart index 28f43c7..64161b2 100644 --- a/lib/views/Dashboard/home/trip_brief_strip.dart +++ b/lib/views/Dashboard/home/trip_brief_strip.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/home/route_brief.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; @@ -89,121 +91,298 @@ class TripBriefStrip extends StatefulWidget { State createState() => _TripBriefStripState(); } +/// ── The figures are reference, so they are folded ── +/// +/// Duration, distance, parcels and payment were drawn permanently, four +/// captioned columns above `TODAY'S RUN`. On a 390×844 phone that block plus +/// the trip tabs plus the app bar reached the fold before the first kitchen, +/// so the screen opened on a dashboard about the run rather than on the run. +/// +/// None of the four is something a rider acts on mid-shift. They are what he +/// checks once when he picks the trip up and again when he reconciles — the +/// definition of reference material, and this file's own doc already said as +/// much about the collapsed face. +/// +/// What stays out is the progress face: stops left, and cash in hand. Those +/// two he acts on. The rest is one tap away and remembers nothing between +/// builds, because a strip that reopens itself is furniture that grows back. class _TripBriefStripState extends State { - /// Starts closed. The brief is a once-a-shift read, and defaulting it open - /// would give back exactly the height this widget exists to reclaim. bool _open = false; Trip get _trip => widget.trip; RouteBrief get _brief => widget.brief; + int get percent => widget.percent; + VoidCallback? get onViewRoute => widget.onViewRoute; /// Stops the rider has finished, derived from the percentage the page already /// computed rather than recounting the sets here. - int get _done => (_trip.stopCount * widget.percent / 100).round(); + int get _done => (_trip.stopCount * percent / 100).round(); @override Widget build(BuildContext context) { - return Padding( - // 12, which is the page gutter — the same value `_footerBar` uses and the - // one the header this replaced used. It reads as 28 when [TripCard] is - // composed whole (its own 16 plus this), and as 12 on Home, which calls - // the three sections directly so that the trip tabs can be a pinned - // sliver between them. The stop rows deliberately sit 4 further in; see - // the gutter group in `card_density_test.dart`. - padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 4.h), - child: Container( - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - // One surface, one shadow. The stack this replaced nested a shadowed - // card inside padding inside three more shadowed cards — four - // elevations describing one object. - boxShadow: const [ - BoxShadow( - color: Color(0x14000000), - blurRadius: 16, - offset: Offset(0, 5), + // ── The brief is a working region, so it stands on one ── + // + // This was a bare `Padding` on Home's ground, and Home's ground is the + // canvas: grey figures on grey, with nothing behind them. [MilerPanel] is + // layer 1 of the surface ladder — white, no border — and the canvas + // showing around it is what turns a column of text into a region. + // + // The panel also settles a gutter argument. The old note here claimed 12 + // put this head "where `TODAY'S RUN` and the timeline's own gutter already + // sit"; measured, they were four points apart. Both sections now take + // their edge from `MilerSurface.panelGutter` + `panelPad`, so they cannot + // disagree — and `home_gutter_test` fails if they ever do. + return MilerPanel( + // ── Not the bordered card that was removed from here ── + // + // That one was a `pureSurface` fill inside a `borderSubtle` outline on a + // near-white page: #FFFFFF on #F8FAFC, a four-percent step, so the + // border did all of the separating and the fill did none. Removing it + // was right for that ground. + // + // The ground changed. The canvas moved to #DEE3EA and white now stands + // off it at 1.290 : 1, which is a surface rather than a hairline — so + // this returns as a *fill with no border*, which is the thing the old + // box was only pretending to be. + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + expanded: _open, + label: _open ? 'Hide trip figures' : 'Show trip figures', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _open = !_open), + child: _head(), ), - ], - ), - // Grows and shrinks in place rather than the body appearing in one - // frame, so the stop list below is pushed down visibly and the rider - // can see where the extra content came from. - child: AnimatedSize( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [_face(), if (_open) _body()], ), - ), + ClipRect( + child: AnimatedSize( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _open + ? _body() + : const SizedBox(width: double.infinity, height: 0), + ), + ), + ], ), ); } - // ── The collapsed face ─────────────────────────────────────────────────── - // - // Ring, the two live figures, the cash chip, the chevron. Nothing else ever - // appears here. - Widget _face() { - final radius = BorderRadius.circular(DesignConstants.radiusXl); - final cash = _trip.cashToCollect; + // ── The head ───────────────────────────────────────────────────────────── - return Semantics( - button: true, - expanded: _open, - label: _open ? 'Hide trip details' : 'Show trip details', - child: Material( - color: Colors.transparent, - borderRadius: radius, - child: InkWell( - onTap: () => setState(() => _open = !_open), - borderRadius: radius, - child: Padding( - padding: EdgeInsets.fromLTRB(14.w, 12.h, 10.w, 12.h), - child: Row( + /// ── The progress rule is gone, and so is the disclosure ── + /// + /// The 3pt `LinearProgressIndicator` under this line was a **thin + /// indeterminate-looking bar directly below the app bar**, which is the exact + /// position and the exact shape every app on the phone uses to say *this + /// screen is still loading*. It was reporting completion, and it was read as + /// a spinner. A signal that is misread by everyone who sees it is worth less + /// than the height it costs, and the sentence above it already gave the same + /// figure in the unit the rider works in. + /// + /// The chevron went with it. The four journey figures behind it were a + /// once-a-shift read that nobody was opening, so the information was shipped + /// and unreachable. They are on the face now — one card, always open, which + /// is the state the rider only ever saw by accident before. + Widget _head() { + final cash = _trip.cashToCollect; + final total = _trip.stopCount; + final left = total - _done; + final complete = total > 0 && left <= 0; + final pct = total > 0 ? (_done / total).clamp(0.0, 1.0) : 0.0; + + // ── A figure, not a sentence ── + // + // "15 stops left" set in body type was a headline wearing a paragraph's + // clothes: the one number a rider steers his shift by, at the same weight + // as everything around it. Every dashboard he already reads — the duty + // apps, the pay apps — leads with the figure big and captions it small, + // because a glance lands on a numeral long before it lands on a phrase. + // So the count is the headline and the words are its caption. + // + // ── And the bar is back, on different terms ── + // + // A progress bar was removed from this widget once, correctly: 3pt thin, + // drawn directly under the app bar, it sat in the exact position and + // shape every app uses for "this screen is loading". What returns is not + // that bar. It lives inside the card, under its own numeral, above its + // own caption ("9 of 24 done"), 6pt with rounded caps and a visible + // determinate fill — the grammar of completion, not of waiting. Colour + // follows the day: brand while the run is live, green when it closes. + return Padding( + padding: EdgeInsets.symmetric(vertical: 14.h), + // Everything in the hero is a figure or a figure's caption, and the + // card's own precedent holds: numbers are read at a glance, not + // studied, so they stop scaling at 1.3× while the prose elsewhere + // keeps going. Unclamped, "3 stops left" at 2.0× on a 320pt phone + // overflowed its own row by 31px. + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - _MiniRing(percent: widget.percent), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$_done of ${_trip.stopCount} ' - '${_trip.stopCount == 1 ? 'stop' : 'stops'} done', + if (complete) ...[ + Icon( + LucideIcons.circleCheck, + size: 22.sp, + color: ColorConstants.acceptGreen, + ), + SizedBox(width: 8.w), + Flexible( + child: Text( + 'All $total done', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ] else ...[ + Text( + '$left', + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w800, + letterSpacing: -1, + height: 1.0, + fontFeatures: const [FontFeature.tabularFigures()], + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 7.w), + Flexible( + child: Padding( + padding: EdgeInsets.only(top: 6.h), + child: Text( + left == 1 ? 'stop left' : 'stops left', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: ColorConstants.slateText, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), - SizedBox(height: 2.h), - _timeLeftLine(), - ], + ), ), - ), - if (cash > 0) ...[SizedBox(width: 8.w), _cashChip(cash)], - SizedBox(width: 2.w), + ], + const Spacer(), + if (cash > 0) ...[_cashChip(cash), SizedBox(width: 8.w)], AnimatedRotation( turns: _open ? 0.5 : 0, - duration: const Duration(milliseconds: 220), + duration: DesignConstants.motionState, curve: Curves.easeOutCubic, child: Icon( - Icons.keyboard_arrow_down_rounded, - size: 24.sp, + LucideIcons.chevronDown, + size: 20.sp, color: ColorConstants.secondaryText, ), ), ], ), - ), + SizedBox(height: 12.h), + // The track is the page's own canvas tone — the one grey that + // measurably separates from this white card (1.29:1) — and the + // fill starts from zero honestly: an untouched run shows an empty + // track, which with the numeral above it cannot be misread. + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + child: Container( + height: 6.h, + width: double.infinity, + color: MilerSurface.canvas, + alignment: Alignment.centerLeft, + child: AnimatedFractionallySizedBox( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + widthFactor: pct, + child: Container( + decoration: BoxDecoration( + color: complete + ? ColorConstants.acceptGreen + : ColorConstants.primary, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + ), + ), + ), + ), + SizedBox(height: 9.h), + Row( + children: [ + Text( + '$_done of $total done', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + const Spacer(), + // The clock keeps its own colour rules — the one line here + // allowed to carry bad news; see [_timeLeftLine]. With no + // shift and no slot the journey speaks instead, and yields + // while the grid below is open so nothing prints twice. + Flexible( + child: (!_brief.shiftSet && _trip.slotStart == null) + ? (_open ? const SizedBox.shrink() : _journeyGlance()) + : _timeLeftLine(), + ), + ], + ), + ], + ), + ), + ); + } + + /// The collapsed panel's journey, in one quiet line: `≈4h · 38.9 km`. + /// + /// Only the figures that exist — a route with no coordinates prints no + /// kilometres rather than a zero — and never a colour: this is context, and + /// the headline beside it is the thing being contextualised. + Widget _journeyGlance() { + final duration = roundTripDuration(_trip.totalDuration); + final parts = [ + if (duration > Duration.zero) '\u2248${formatTripDuration(duration)}', + if (_trip.routeMeters > 0) + RouteMetricsHelper.formatDistance(_trip.routeMeters), + ]; + if (parts.isEmpty) return const SizedBox.shrink(); + // Scale-down, never ellipsis: this line is nothing but figures, and + // `10.3 …` is the unit cut off the number it qualifies — seen the first + // time it shared a row with the headline, both flexible. + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + parts.join(' \u00b7 '), + maxLines: 1, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, ), ), ); @@ -221,6 +400,13 @@ class _TripBriefStripState extends State { // The hub has not set a window. Say nothing about time rather than // inventing a state — see the note on [ShiftBanner] for why an absent // shift is not the rider's problem to solve. + // + // And when the trip has no slot either, say nothing AT ALL: + // `slotLabel`'s fallback is the words "Today's route", which set at + // clock position read as a fact the rider should be able to act on. A + // headline row carries a live figure or it carries silence — a label + // pretending to be data is worse than the gap it fills. + if (_trip.slotStart == null) return const SizedBox.shrink(); text = _trip.slotLabel; color = ColorConstants.secondaryText; } else if (left == Duration.zero) { @@ -232,7 +418,7 @@ class _TripBriefStripState extends State { text = '${formatDuration(left)} left · running tight'; color = ColorConstants.warning; } else { - text = '${formatDuration(left)} left in shift'; + text = '${formatDuration(left)} left'; color = ColorConstants.secondaryText; } @@ -242,29 +428,30 @@ class _TripBriefStripState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: color, fontFamily: FontConstants.fontFamily, ), ); } + /// ── The cash is a figure, not a chip ── + /// + /// It was a tinted pill, and a pill has vertical padding — which made the one + /// element on the line that could **change the height of the whole strip** + /// depend on whether the trip happened to carry money. Green on the page + /// carries the meaning perfectly well without a capsule behind it. Widget _cashChip(double cash) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h), - decoration: BoxDecoration( - color: ColorConstants.acceptGreen.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - ), - child: Text( - '₹${_money(cash)}', - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: ColorConstants.moneyGreen, - fontFamily: FontConstants.fontFamily, - ), + return Text( + '\u20b9${_money(cash)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: ColorConstants.moneyGreen, + fontFamily: FontConstants.fontFamily, ), ); } @@ -300,12 +487,13 @@ class _TripBriefStripState extends State { final duration = roundTripDuration(_trip.totalDuration); return Padding( - padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 14.h), + // Horizontally flush with [_head], for the same reason. + padding: EdgeInsets.only(bottom: 14.h), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Divider(height: 1, color: ColorConstants.borderSubtle), - SizedBox(height: 10.h), + SizedBox(height: 14.h), // ── Band 1: the shift window ── // @@ -322,7 +510,7 @@ class _TripBriefStripState extends State { // rather than as a range. if (_brief.shiftSet) ...[ _windowRow( - icon: Icons.event_available_rounded, + icon: LucideIcons.calendarCheck, label: 'Shift', value: _brief.shiftWindowLabel, tint: ColorConstants.slateText, @@ -342,21 +530,24 @@ class _TripBriefStripState extends State { children: [ Expanded( child: _figure( - icon: Icons.schedule_rounded, + icon: LucideIcons.clock, value: '≈${formatTripDuration(duration)}', label: 'DURATION', ), ), Expanded( child: _figure( - icon: Icons.route_rounded, + // `straighten`, not `route`: the winding-path glyph is + // four strokes and two pins, and at this size it renders as + // a smudge. An icon the rider cannot resolve is decoration. + icon: LucideIcons.ruler, value: RouteMetricsHelper.formatDistance(_trip.routeMeters), label: 'DISTANCE', ), ), Expanded( child: _figure( - icon: Icons.inventory_2_rounded, + icon: LucideIcons.package, value: '${_trip.totalParcels}', label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS', ), @@ -364,7 +555,7 @@ class _TripBriefStripState extends State { Expanded( child: cash > 0 ? _figure( - icon: Icons.payments_rounded, + icon: LucideIcons.banknote, value: '₹${_money(cash)}', label: 'PAYMENT', money: true, @@ -373,8 +564,13 @@ class _TripBriefStripState extends State { // four even columns instead of redistributing the space // and shifting the other three figures sideways. : _figure( - icon: Icons.money_off_rounded, - value: 'None', + icon: LucideIcons.banknoteX, + // The em dash, not a word: '—' is what every other + // figure in the app prints when there is nothing to + // print, and 'None' set in figure type read as data + // — a rider scanning four numbers got three numbers + // and a word to parse. + value: '\u2014', label: 'PAYMENT', muted: true, ), @@ -413,7 +609,7 @@ class _TripBriefStripState extends State { label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -427,7 +623,7 @@ class _TripBriefStripState extends State { textAlign: TextAlign.right, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -487,7 +683,7 @@ class _TripBriefStripState extends State { value, style: TextStyle( fontSize: 16.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.05, color: accent, @@ -505,7 +701,7 @@ class _TripBriefStripState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 9.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.5, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -532,59 +728,3 @@ class _TripBriefStripState extends State { return parts.length > 1 ? '${buf.toString()}.${parts[1]}' : buf.toString(); } } - -/// The completion ring, at a third of the area it used to occupy. -/// -/// It was 66dp and sat beside a 25sp headline; it is 34 here and sits beside a -/// 15sp line, so it holds the same relative weight in a strip that is a fifth -/// of the height. The percentage itself is not printed inside it — the pinned -/// trip tab below already prints "50% done", and the fraction next to the ring -/// says the same thing in stops, which is the unit the rider works in. -class _MiniRing extends StatelessWidget { - final int percent; - const _MiniRing({required this.percent}); - - @override - Widget build(BuildContext context) { - final complete = percent >= 100; - final color = complete - ? ColorConstants.acceptGreen - : ColorConstants.primary; - - return SizedBox( - width: 34.w, - height: 34.w, - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox.expand( - child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: percent / 100), - duration: const Duration(milliseconds: 550), - curve: Curves.easeOutCubic, - builder: (_, value, __) => CircularProgressIndicator( - value: value, - strokeWidth: 3.5, - backgroundColor: ColorConstants.borderSubtle, - valueColor: AlwaysStoppedAnimation(color), - ), - ), - ), - if (complete) - Icon(Icons.check_rounded, size: 17.sp, color: color) - else - Text( - '$percent', - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.4, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ); - } -} diff --git a/lib/views/Dashboard/home/trip_card.dart b/lib/views/Dashboard/home/trip_card.dart index ffebdd5..58d2e6e 100644 --- a/lib/views/Dashboard/home/trip_card.dart +++ b/lib/views/Dashboard/home/trip_card.dart @@ -1,14 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:miler/views/Dashboard/home/route_brief.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/Dashboard/home/stop_card.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_brief_strip.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; -import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// TRIP CARD — one slot's whole route, start to finish. @@ -75,6 +85,13 @@ class TripCard extends StatelessWidget { /// Opens the whole route on a map. Null hides the action. final VoidCallback? onViewRoute; + /// Takes the rider back into the stop he is already working. + /// + /// This is the way in that used to be a red banner floating over the foot of + /// the page — see the note on the live row in [_stopRow]. Null renders the + /// active row as a plain statement instead. + final void Function(Map stop)? onContinueStop; + /// Shift window, cash, parcels and pace for the rider's whole day. /// /// Supplied by the page because it spans every trip, not just this one, and @@ -87,6 +104,35 @@ class TripCard extends StatelessWidget { /// its acceptance is on the wire. final Set busyStopIds; + /// Orders the rider is carrying. Service routes only — see [StopState.collected]. + final Set collectedIds; + + /// Opens turn-by-turn navigation for one stop. + /// + /// On the row itself, not only in the detail sheet: setting off is the single + /// most common thing a rider does from this list, and making him open a sheet + /// first is a tap per stop on a screen he uses ten times a morning. Where it + /// points follows the stop's stage — see [MilkRun.navigationTarget]. + final void Function(Map stop)? onNavigateStop; + + /// Walks one stop up its next rung — accepted → arrived → picked — through a + /// slide-to-confirm sheet. + /// + /// The selection bar does this for a ticked set; this is the single-stop path + /// for a rider standing at one counter with one card in front of him. + final void Function(Map stop)? onAdvanceStop; + + /// Opens the pickup preview for a whole place — the leg to it and the load + /// waiting on it. See [PickupPreviewSheet]. + final void Function(RouteGroup group)? onPreviewGroup; + + /// Where the rider is, for "how far is the next pickup". + /// + /// Optional, and the timeline degrades cleanly without it: no fix means no + /// distance line rather than a fabricated one. See [RouteGroup.meters]. + final double? riderLat; + final double? riderLng; + const TripCard({ super.key, required this.trip, @@ -101,14 +147,36 @@ class TripCard extends StatelessWidget { this.onStopTap, this.onCallStop, this.onViewRoute, + this.onContinueStop, this.busyStopIds = const {}, + this.collectedIds = const {}, + this.onNavigateStop, + this.onAdvanceStop, + this.onPreviewGroup, + this.riderLat, + this.riderLng, }); - StopState _stateOf(Map stop) => - stopStateOf(stop, acceptedIds: acceptedIds, rejectedIds: rejectedIds); + StopState _stateOf(Map stop) => stopStateOf( + stop, + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + collectedIds: collectedIds, + ); String _idOf(Map stop) => (stop['orderid'] ?? '').toString(); + /// ── The tick outlives the accept ── + /// + /// On a parcel route the box is only for choosing what to take on, so it goes + /// once the stop is taken. A service rider keeps ticking the same stops + /// afterwards: he selects every order from one kitchen and walks them through + /// *arrived* and *picked* together, which is what the bar's ladder acts on. + static bool _isChoosable(StopState state) => + state == StopState.pending || + (ServiceProfile.active.handsOffAtCollection && + (state == StopState.accepted || state == StopState.active)); + @override Widget build(BuildContext context) { // No frame around the trip. @@ -143,7 +211,12 @@ class TripCard extends StatelessWidget { // Home is wider than Bookings' 16 on purpose: it is a much denser screen — // shift card, three stat cards, a pinned tab strip and a rail, all above // the route — and at 16 the column read as pressed against the border. - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 2.h), + // Horizontal inset is zero here now: each section stands on its own + // [MilerPanel], which carries `MilerSurface.panelGutter`. Keeping 16 as + // well would inset the panels by 28 in this whole-card path while Home — + // which places the three sections directly — drew them at 12, so the + // reference shots would not be pictures of the screen. + padding: EdgeInsets.symmetric(vertical: 2.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [buildSummary(), buildStops(), buildActionBar()], @@ -183,19 +256,316 @@ class TripCard extends StatelessWidget { ); /// HUB → every stop in order → HUB. - Widget buildStops() => _sequence(); + Widget buildStops() => _timeline(); + + // ── The "NEXT ACTION" head is gone, and its job moved down one line ─────── + // + // It was a glass card above the route carrying the place name at 25sp, the + // load, the travel time and a full-width red **Navigate to pickup** — and the + // group for that same place, forty points below, carried the identical button + // over the identical name. Two primary actions, one destination, and the + // rider had to read both to find out they were the same. + // + // Everything it said is now said once, by the group node that owns it: the + // name, the state chip, `5 orders · 5 bags`, the distance and ETA, and one + // journey button. That is the whole point of a timeline — the head *is* the + // first node — and it gave back ~190pt of the viewport. + // + // What is not lost: [_nextGroup] still resolves the current unit of work for + // the callers below. + + /// The next place the rider owes work to, and every order he has there. + /// + /// Ordered by *rung*, not by route position: a counter he has already reached + /// outranks one he has only accepted, because a rider standing at a shelf is + /// mid-handover and everything else can wait. Within a rung it is the first + /// stop in route order, which is the order the hub built. + ({ + List> stops, + String place, + StopState state, + int index, + })? + _nextGroup() { + for (final rung in const [StopState.active, StopState.accepted]) { + for (var i = 0; i < trip.stops.length; i++) { + if (_stateOf(trip.stops[i]) != rung) continue; + final stop = trip.stops[i]; + final key = MilkRun.sourceKeyOf(stop); + final grouped = ServiceProfile.active.sourceIsKitchen && key.isNotEmpty; + final stops = grouped + ? [ + for (final s in trip.stops) + if (MilkRun.sourceKeyOf(s) == key && _stateOf(s) == rung) s, + ] + : [stop]; + final place = grouped + ? stopSourceName(stop) + : MilkRun.navigationLabel(stop); + if (place.trim().isEmpty) continue; + return (stops: stops, place: place, state: rung, index: i); + } + } + return null; + } /// Accept-remaining / selection bar, or the finished confirmation. Widget buildActionBar() => _footerBar( trip.pendingOrderIds(acceptedIds: acceptedIds, rejectedIds: rejectedIds), ); + // ── The run, as a timeline ─────────────────────────────────────────────── + // + // This replaced `_sequence()`: a select-all strip, a hub row, a heading per + // kitchen, one [StopCard] per order and a closing hub row. The cards were the + // cost — 138pt each, so five orders off one counter came to ~690pt against a + // ~600pt viewport, and a rider working one kitchen could not see one kitchen. + // + // The stops are the same stops and the state is the same state; what changed + // is that the *place* became the node and the orders became milestones under + // it. See [RouteTimeline]. + Widget _timeline() { + final groups = routeGroups(); + if (groups.isEmpty) { + return Padding( + padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 8.h), + child: trip.stops.isEmpty ? const SizedBox.shrink() : _allTakenNote(), + ); + } + + // ── The route is the screen's working region ── + // + // Same move as the brief above it: Home's ground is the canvas, and a + // spine of stops drawn straight onto it is grey on grey. [MilerPanel] + // gives the route a white layer-1 surface to stand on — one panel for the + // whole route, not one per kitchen, which is the distinction the earlier + // pass was after when it removed the card-per-kitchen. + return MilerPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RouteTimeline( + groups: groups, + selectedIds: selectedIds, + busyIds: busyStopIds, + onSelectAll: onSelectAll, + onToggleSelect: onToggleSelect, + onStopTap: onStopTap, + // One journey action per place. Five Navigate buttons pointing at + // one counter was the largest single source of clutter on the old + // screen, and the rider had to work out whether they differed. + onNavigateGroup: onNavigateStop, + // Ringing the kitchen is an action on the *place*, so it sits on + // the pickup card beside Navigate — not on five order rows that all + // dial the same counter. + onCallGroup: onCallStop, + // The card's own tap, and what Navigate goes through. Supplied by + // the page rather than built here: these three methods are pure and + // take no `BuildContext` — Home calls them directly as slivers — + // and a sheet needs one. + onPreviewGroup: onPreviewGroup, + // The way back into the stop he is already inside. This is what + // replaced the red banner that used to float over the foot of the + // page — see [PickupTimelineGroup._action]. + onContinue: onContinueStop, + onUnreject: onUnrejectStop == null + ? null + : (stop) => onUnrejectStop!(stop), + onViewRoute: onViewRoute, + ), + // A meal run does not come back: the rider finishes at the last + // subscriber's door and goes home. A parcel day is a loop, so it + // still ends where it started. + if (!ServiceProfile.active.sourceIsKitchen) ...[ + SizedBox(height: 6.h), + _returnNote(), + ], + if (trip.stops.isNotEmpty && + !trip.stops.any((s) => _showsOnHome(_stateOf(s)))) + _allTakenNote(), + ], + ), + ); + } + + /// The run's pickup places, in the order the route reaches them. + /// + /// On a line with sources this is one group per kitchen. On a parcel route + /// there are none, so every stop becomes its own single-order group — the + /// same timeline one level shallower, which is honest: a parcel rider really + /// does ride to each address for one consignment. + /// + /// A group whose orders have all left Home — collected, and now on the work + /// tab — emits nothing at all. Its heading would be a title over a gap. + @visibleForTesting + List routeGroups() { + final order = []; + final buckets = >{}; + final grouped = ServiceProfile.active.sourceIsKitchen; + + // Every stop is bucketed, not only the ones Home still draws: a group's bag + // numbers are counted against the whole group the rider was handed, so the + // collected ones have to stay in the arithmetic even once they have left + // the screen. See [RouteGroup.allStops]. + for (var i = 0; i < trip.stops.length; i++) { + final stop = trip.stops[i]; + // Key on the id where there is one, falling back to the name: the id is + // what the hub controls, but a payload that only carries a name still has + // to group rather than collapsing every stop into one nameless pile. + final key = grouped + ? (stopSourceId(stop).isNotEmpty + ? stopSourceId(stop) + : stopSourceName(stop)) + : 'stop:${_idOf(stop)}'; + if (!buckets.containsKey(key)) { + buckets[key] = []; + order.add(key); + } + buckets[key]!.add(i); + } + + final groups = []; + for (final key in order) { + final indices = buckets[key]!; + final all = [for (final i in indices) trip.stops[i]]; + final visible = [ + for (final s in all) + if (_showsOnHome(_stateOf(s))) s, + ]; + // ── A finished place settles; it does not vanish ── + // + // A group with nothing left on Home is one the rider has collected in + // full. It stays as one settled line — a green node and a "Collected" + // chip — so the run still reads as a sequence and he can see 01 is behind + // him. The work itself has moved to the work tab; what is left here is + // the fact that it did. A group that is neither visible nor collected + // (all rejected, all finished on the server) emits nothing. + // **Collected**, not merely finished. A stop the rider is carrying has + // moved to the work tab and the place he took it from is behind him — a + // fact worth one settled line. A stop that is *done* is finished work and + // belongs to Activity; drawing its place here would put yesterday's + // counters on today's run. + final settled = + visible.isEmpty && + all.every((s) => _stateOf(s) == StopState.collected); + if (visible.isEmpty && !settled) continue; + groups.add(() { + final stops = visible; + final first = all.first; + final flat = + all.length == 1 && (!grouped || stopSourceName(first).isEmpty); + // A grouped place is named by its counter. A flat one *is* the stop, so + // it is named by the customer — `MilkRun.navigationLabel` answers + // "pickup" for a parcel that has not been collected yet, which is a + // description of the leg rather than a name the rider can read off a + // gate. + final name = flat + ? BagManifest.customerOf(first) + : (grouped + ? stopSourceName(first) + : MilkRun.navigationLabel(first)); + return RouteGroup( + key: key, + // A route with no source names still groups — into one unnamed + // group — and a heading reading "· 5 orders" would be worse than + // the customer's own name. + name: name.trim().isEmpty + ? MilkRun.navigationLabel(first) + : name.trim(), + stops: stops, + allStops: all, + firstIndex: indices.first, + state: stops.isEmpty ? StopState.collected : _groupRung(stops), + stopStates: [for (final s in stops) _stateOf(s)], + travel: trip.travelTimeToStop(indices.first), + // ── Measured, or absent ── + // + // Rider → this place, from a live fix where there is one and from + // whatever the booking carries otherwise. `metersToStop` returns null + // rather than zero when it has nothing to work with, and the header + // prints nothing rather than "0 m" — a distance is a thing the rider + // plans around, so a wrong one is worse than a missing one. + meters: RouteMetricsHelper.metersToStop( + first, + riderLat: riderLat, + riderLng: riderLng, + ), + // One order at one address with no counter above it worth naming: + // the header is the stop. Always true on a parcel route, where the + // rider rides to each customer for one consignment — a header plus a + // single child would print the same name twice. + flat: flat, + ); + }()); + } + return groups; + } + + /// The rung a whole group is on: the state of its first order that the rider + /// can still act on, falling back to the first order's own state. + /// + /// A selection spanning two rungs makes the bar fall back to Accept, so the + /// group only ever gathers one — the same rule the old kitchen heading used. + StopState _groupRung(List> stops) { + for (final s in stops) { + final state = _stateOf(s); + if (_isChoosable(state)) return state; + } + return _stateOf(stops.first); + } + + /// The closing leg of a parcel day, as a line rather than a node-and-band. + Widget _returnNote() => Padding( + padding: EdgeInsets.only(left: 38.w, top: 2.h), + child: Row( + children: [ + Icon( + LucideIcons.warehouse, + size: 14.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 8.w), + // Flexible: a tracked-out eyebrow at 2.0× text scale on a 320pt phone + // is wider than the row it sits in, and a bare `Text` in a `Row` has no + // give. `trip_brief_layout_test.dart` sweeps for exactly this. + Flexible( + child: Text( + ServiceProfile.active.endsAtHub ? 'RETURN · HUB' : 'END · HOME', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.eyebrow, + ), + ), + ], + ), + ); + // ── HUB → stops → HUB ────────────────────────────────────────────────── + // + // Retained only for the tests that still pin the old composition. Nothing on + // Home calls it — see [_timeline]. + // ignore: unused_element Widget _sequence() { // Computed once for the whole trip, not per stop — it is a comparison // across all of them. final addressTail = trip.sharedAddressTail; + // ── One journey button per screen ── + // + // The head above the list already offers "Navigate to pickup" for the + // current place. Repeating it on each of that place's five cards is five + // more buttons that do the identical thing, on the screen whose whole + // premise is that there is exactly one next action — and the rider then has + // to work out whether they differ. They do not, so only the head has it. + // + // Cards for *other* places keep theirs: those are journeys the head is not + // offering. + final head = _nextGroup(); + final headIds = { + if (head != null) + for (final s in head.stops) _idOf(s), + }; + return Padding( // ── The stop rows sit inside the summary cards, symmetrically ── // @@ -262,15 +632,183 @@ class TripCard extends StatelessWidget { // report 0 of 1 and empty its own ring every time he took something. // The indices are the trip's own too, so the numbering does not // renumber itself as the day goes on. - for (var i = 0; i < trip.stops.length; i++) - if (_showsOnHome(_stateOf(trip.stops[i]))) _stopRow(i, addressTail), + // ── Grouped by source, when there is one ── + // + // A service rider's day is two stops then ten doors, and a flat list + // of ten hides that shape completely: he cannot see that five of them + // come from one kitchen and five from another until he opens each + // card. Grouping states it, and gives the bulk collect somewhere to + // live that is unambiguously about *those five*. + if (ServiceProfile.active.sourceIsKitchen) + ..._groupedRows(addressTail, headIds) + else + for (var i = 0; i < trip.stops.length; i++) + if (_showsOnHome(_stateOf(trip.stops[i]))) + _stopRow(i, addressTail, headIds), // A trip whose every stop has been taken or finished still has hub // rows and a brief, and would otherwise show an unexplained gap // between them. if (trip.stops.isNotEmpty && !trip.stops.any((s) => _showsOnHome(_stateOf(s)))) _allTakenNote(), - _hubRow(isStart: false), + // ── A meal run does not come back ── + // + // The parcel day is a loop: leave the hub empty, fill up at customer + // doors, return with the consignments — so "RETURN · HUB" is the last + // real thing on the route and the rider is not finished until he is + // there. + // + // A meal rider finishes at the last subscriber's door and goes home. + // Drawing him a return leg to a warehouse he has never been to states + // a journey nobody is making, and puts an un-completable step at the + // end of a route he has in fact completed. + if (!ServiceProfile.active.sourceIsKitchen) _hubRow(isStart: false), + ], + ), + ); + } + + /// Stop rows under a heading per source, in route order. + /// + /// Groups are emitted in the order their first stop appears, so the sections + /// follow the route the hub built rather than an alphabetical order that + /// would send the rider to the wrong kitchen first. A group whose stops have + /// all left Home — collected, and now on Bookings — emits nothing at all; + /// its heading would be a title over an empty space. + List _groupedRows(List addressTail, Set headIds) { + final order = []; + final groups = >{}; + + for (var i = 0; i < trip.stops.length; i++) { + if (!_showsOnHome(_stateOf(trip.stops[i]))) continue; + // Key on the id where there is one, falling back to the name: the id is + // what the hub controls, but a payload that only carries a name still has + // to group rather than collapsing every stop into one nameless pile. + final stop = trip.stops[i]; + final key = stopSourceId(stop).isNotEmpty + ? stopSourceId(stop) + : stopSourceName(stop); + if (!groups.containsKey(key)) { + groups[key] = []; + order.add(key); + } + groups[key]!.add(i); + } + + return [ + for (final key in order) ...[ + _kitchenHeader(key, groups[key]!), + for (final i in groups[key]!) _stopRow(i, addressTail, headIds), + SizedBox(height: 6.h), + ], + ]; + } + + /// `🏪 Kitchen 1 · 5 meals` with the one action that group affords. + Widget _kitchenHeader(String key, List indices) { + final first = trip.stops[indices.first]; + final name = stopSourceName(first); + // A route with no source names at all still groups — into one unnamed + // group — and a heading reading "· 5 meals" would be worse than none. + if (name.isEmpty) return const SizedBox.shrink(); + + final states = [for (final i in indices) _stateOf(trip.stops[i])]; + final pending = states.where((s) => s == StopState.pending).length; + final accent = ColorConstants.serviceAccent; + + // ── Every order off one counter, in one tick ── + // + // This is the selection the ladder is actually made of. He does not arrive + // at an order, he arrives at a kitchen — so the control that gathers "all + // of this kitchen's orders" belongs on the kitchen's own heading, and the + // bar underneath then speaks for exactly that counter. + // + // The set is the group's orders **on one rung**: whichever rung the first + // choosable stop is on. A selection spanning two rungs makes the bar fall + // back to Accept (see `_selectionAction`), which would be a heading that + // ticks five cards and then offers the wrong verb for three of them. + final groupIds = []; + StopState? rung; + for (final i in indices) { + final state = _stateOf(trip.stops[i]); + if (!_isChoosable(state)) continue; + rung ??= state; + if (state != rung) continue; + final id = _idOf(trip.stops[i]); + if (id.isNotEmpty) groupIds.add(id); + } + final picked = groupIds.where(selectedIds.contains).length; + final allPicked = groupIds.isNotEmpty && picked == groupIds.length; + + return Padding( + padding: EdgeInsets.only(top: 10.h, bottom: 8.h), + child: Row( + children: [ + if (onSelectAll != null && groupIds.isNotEmpty) + SelectBox( + selected: allPicked, + partial: picked > 0 && !allPicked, + accent: ColorConstants.primary, + label: 'Select every order from $name', + circular: true, + onTap: () => onSelectAll!(allPicked ? const [] : groupIds), + ) + else + Icon(LucideIcons.store, size: 16.sp, color: accent), + SizedBox(width: 7.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + // ── One order, one bag ── + // + // This counted "meals", which is a word for the thing inside + // the bag rather than the thing the rider carries — and it left + // him comparing "5 meals" on the phone against a shelf of bags. + // The count he can *check* is orders against bags, and under + // this workflow they are the same number by definition. See + // [BagManifest]. + Text( + pending > 0 + ? '${BagManifest.countLabel(indices.length)} · $pending to accept' + : BagManifest.countLabel(indices.length), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + // ── One bulk flow at a counter, not two ── + // + // A `Collect N` button used to sit here and open a second sheet with + // its own tick-list, alongside the tick-plus-bar ladder below. Two + // ways to do one physical thing is two mental models for a rider + // holding a crate, and the two wrote different state: one posted + // PICKED, the other kept collection local. + // + // The ladder won — it is the same gesture on every rung and every + // line of work — and the load sheet's one irreplaceable feature, the + // missing-bag report, moved into the pickup confirmation where the + // handover is actually confirmed. See [StopActionSheet]. ], ), ); @@ -305,21 +843,23 @@ class TripCard extends StatelessWidget { border: Border.all(color: _hubColor, width: 1.5), ), child: Icon( - Icons.warehouse_rounded, + // Where this rider's day actually begins. A warehouse + // glyph on a meal run points at a building he will never + // visit; his first address is a kitchen counter. + ServiceProfile.active.sourceIsKitchen + ? LucideIcons.store + : LucideIcons.warehouse, size: 12.sp, color: _hubColor, ), ), ), ), - if (isStart) - Expanded( - child: Container( - width: 2, - margin: EdgeInsets.symmetric(vertical: 3.h), - color: ColorConstants.borderSubtle, - ), - ), + // No connector under the node any more. It used to run down into + // a rail of numbered stop nodes drawn *outside* the cards; the + // journey thread now lives inside each card, where it connects + // the two ends of one order, and a stub of line pointing at a + // card edge would be a rail with nothing on it. ], ), SizedBox(width: 8.w), @@ -358,12 +898,26 @@ class TripCard extends StatelessWidget { children: [ Flexible( child: Text( - isStart ? 'START · HUB' : 'RETURN · HUB', + isStart + ? (ServiceProfile.active.sourceIsKitchen + ? 'START · KITCHEN' + : 'START · HUB') + // ── A milk run does not return anything ── + // + // He loads at the kitchen and his last address is a + // customer's door: there is no hub at the end of + // his day and nothing to carry back to one. The + // logistics rider genuinely does return collected + // shipments to the depot, so the label follows the + // capability rather than the screen position. + : (ServiceProfile.active.endsAtHub + ? 'RETURN · HUB' + : 'END · HOME'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.7, color: _hubColor, fontFamily: FontConstants.fontFamily, @@ -437,7 +991,7 @@ class TripCard extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), child: Row( children: [ - _SelectBox( + SelectBox( selected: all, partial: some, accent: ColorConstants.primary, @@ -456,7 +1010,7 @@ class TripCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: picked.isEmpty ? ColorConstants.secondaryText @@ -472,7 +1026,7 @@ class TripCard extends StatelessWidget { all ? 'CLEAR' : 'ALL', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, @@ -488,554 +1042,209 @@ class TripCard extends StatelessWidget { } /// Which states earn a row on Home. See the note at the call site. - static bool _showsOnHome(StopState state) => - state == StopState.pending || state == StopState.rejected; - - /// The single control a rejected row is allowed. /// - /// Outlined and compact, right-aligned: it is a way back, not the thing the - /// screen is for, and a filled button here would out-rank the accept on the - /// floating bar. - Widget _rowAction({ - required String label, - required IconData icon, - required Color color, - required bool busy, - required VoidCallback onPressed, - }) { - return Padding( - padding: EdgeInsets.only(top: 9.h), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: MilerButton( - label: label, - icon: icon, - variant: MilerButtonVariant.outlined, - color: color, - height: ButtonSizes.compact, - expand: false, - loading: busy, - onPressed: busy ? null : onPressed, - ), - ), - ], - ), - ); - } + /// ── Accepted is Home's problem too, on a service run ── + /// + /// For a parcel, accepting is the whole decision and everything after it + /// happens at a customer's door, so an accepted stop belongs on Bookings. + /// + /// Accepting a subscriber's lunch changes nothing physical: the food is still + /// in a kitchen he has not reached. Handing the card over at that moment + /// empties Home the instant he starts his shift and leaves him working a + /// Bookings list of meals he is not carrying. So Home keeps it until he is — + /// see [ServiceProfile.handoffAt]. + /// ── The stop he is on never leaves this screen ── + /// + /// An active stop stays on Home for every tenant now. It used to be hidden on + /// a parcel route and announced instead by a red banner floating over the + /// bottom of the page — a second, separate object saying "you have a job + /// running" about a stop the list underneath was refusing to show. The row + /// itself is the better place for it: it already has the customer, the + /// address, the parcels and the position in the route, and it is where the + /// rider is looking. + /// ── A declined stop is history, not a row ── + /// + /// `rejected` used to belong here, so a stop the rider had turned down stayed + /// on Home struck through with an UNDO beside it — a row he had finished with, + /// occupying the screen he uses to decide what is next, for the rest of the + /// shift. On a run where he declines three, that is three permanent rows of + /// work that is not his. + /// + /// Declining is a decision, and decisions are recorded on Activity. It files + /// there the moment he makes it — see `_rejectStop` — and the undo goes with + /// it: an action that reverses itself from the page it was performed on is + /// what kept the row here in the first place. + static bool _showsOnHome(StopState state) => + state == StopState.pending || + state == StopState.active || + // Accepted stays only where the rider works that rung from this screen; + // on a parcel route it moves to Bookings the moment he takes it. + (state == StopState.accepted && + ServiceProfile.active.handsOffAtCollection); + + /// The lifecycle status a Home row's state corresponds to. + /// + /// [StopState] is the screen's vocabulary and [StopStatus] is the lifecycle's; + /// this is the one place they are mapped, so the row's button and the sheet's + /// heading cannot end up describing different rungs. + StopStatus _rungOf(StopState state) => switch (state) { + StopState.accepted => StopStatus.accepted, + StopState.active => StopStatus.arrived, + _ => StopStatus.unknown, + }; /// Shown in place of the stop list when every stop in the trip has been taken /// or finished — so the gap between the two hub rows says what happened /// instead of looking like a failed load. + /// + /// An illustration now, per the user's direction (2026-08-20) — the sentence + /// it replaces ("Every stop on this trip is taken — work them from …") lives + /// on as the image's semantic label, so a screen reader still gets the fact + /// and the handoff pill below still names the tab to go to. Widget _allTakenNote() { return Padding( - padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 8.w), - child: Row( - children: [ - Icon( - Icons.check_circle_rounded, - size: 18.sp, - color: ColorConstants.acceptGreen, + padding: EdgeInsets.symmetric(vertical: 38.h, horizontal: 8.w), + child: Center( + child: Semantics( + label: + 'Every stop on this trip is taken — work them from ' + '${ServiceProfile.active.workTabLabel}', + image: true, + child: Image.asset( + 'assets/images/bookings_taken.png', + width: 280.w, + height: 280.w, + fit: BoxFit.contain, + // A missing asset must degrade to nothing, never to the grey + // missing-image box in the middle of Home. + errorBuilder: (_, _, _) => const SizedBox.shrink(), ), - SizedBox(width: 9.w), - Expanded( - child: Text( - 'Every stop on this trip is taken — work them from Bookings', - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w700, - height: 1.35, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], + ), ), ); } - Widget _stopRow(int index, List addressTail) { + /// One stop, as one card. + /// + /// Everything about *shape* now lives in [StopCard] — the journey thread, the + /// two legs, the footer. What stays here is the trip's own state: which + /// controls this stop has earned, and what each of them does. + Widget _stopRow( + int index, + List addressTail, [ + Set headIds = const {}, + ]) { final stop = trip.stops[index]; - final kind = stopKindOf(stop); final state = _stateOf(stop); final id = _idOf(stop); final stopBusy = busyStopIds.contains(id); - - final rejected = state == StopState.rejected; - final done = state == StopState.done; - final pending = state == StopState.pending; - final accepted = state == StopState.accepted; - final selected = selectedIds.contains(id); - // Accepted counts as locked here. See the decision row below for why an - // accepted stop no longer offers Reject beside its own confirmation. - final locked = done || accepted || state == StopState.active; - - // ── Colour encodes STATE, not stop type ── - // - // This was `kind.accent`, so the rail ran maroon / royal-blue / indigo down - // the list according to whether each stop was a pickup, a delivery or both. - // That spends the strongest signal available on a *category* — a fact that - // never changes and that the label already states in words — and leaves - // nothing to say the thing that does change: how far through the trip he is. - // - // Now the rail reads as progress, which is what a rail is for. - final Color nodeColor = rejected - ? ColorConstants.secondaryText - : done || accepted - ? ColorConstants.acceptGreen - : state == StopState.active - ? ColorConstants.primary - : ColorConstants.slateText; - - final name = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop') - .toString(); final phone = (stop['pickupcontactno'] ?? '').toString().trim(); - // City / state / pincode stripped — see [commonAddressTail]. What is left - // is the only part that tells this stop from the next one. - // Trimmed to what distinguishes this stop from the next, unless the design - // trial is on — see [kFullAddressTrial]. + + // City / state / pincode stripped — see [commonAddressTail]. What is left is + // the only part that tells this stop from the next one. Unless the design + // trial is on, in which case the raw address is used; see + // [kFullAddressTrial]. final address = kFullAddressTrial ? (stop['pickupaddress'] ?? '').toString() : trip.shortAddress(stop, addressTail); - final deliver = deliveryParcelCount(stop); - final collect = pickupParcelCount(stop); - final cash = stopCollectionAmount(stop); - final service = roundTripDuration(Trip.serviceTimeFor(stop)); - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Rail ── - // Narrowed again, 26px + 10px gutter → 22px + 8px. The node carries - // one digit or one tick; everything past that was width borrowed from - // the address, and the address is what tells one stop from the next. - Column( - children: [ - // Nudged down so the circle centres on the badge beside it - // instead of sitting flush with the top edge — the flush - // alignment made stop 1 look pinned to the corner and squeezed - // the gap above it to nothing. - SizedBox(height: 10.h), - Container( - width: 22.w, - height: 22.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: rejected ? ColorConstants.pureSurface : nodeColor, - shape: BoxShape.circle, - border: Border.all(color: nodeColor, width: 2), - ), - child: done - ? Icon( - Icons.check_rounded, - size: 13.sp, - color: Colors.white, - ) - : rejected - ? Icon(Icons.close_rounded, size: 12.sp, color: nodeColor) - : Text( - '${index + 1}', - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w800, - color: Colors.white, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - Expanded( - child: Container( - width: 2, - margin: EdgeInsets.symmetric(vertical: 3.h), - color: done - ? ColorConstants.acceptGreen - : ColorConstants.borderSubtle, - ), - ), - ], - ), - SizedBox(width: 8.w), + final choosable = _isChoosable(state); - // ── Stop body ── - // - // Now the only card on the screen. With the trip's outer frame gone - // there is exactly one border left, and it is drawn around the thing - // the rider is actually deciding about: this stop. - // - // ── One surface, one border ── - // - // A pending stop used to be filled with its *type* accent at 5% and - // bordered with the same colour at 38%, so the list came down the - // screen as a lavender card, then a pink card, then a blue one — a - // different tinted pane per stop kind. Uber's Base system is explicit - // about why that is wrong: surfaces are white or one neutral grey, - // borders are a single neutral, and the accent is reserved for "one - // moment per screen" because it is "the only thing that says this - // matters". Tinting by category spends that on a fact that never - // changes and that the label spells out in words anyway. - // - // It also did not survive the environment. The same 5% fill that - // reads as a soft tint indoors is a grey smudge on a phone held at - // arm's length in sunlight — the exact failure this file already - // documents for badges on the Bookings card. - // - // Nothing is lost by removing it, because the tint was never what - // told the rider a stop was unanswered: a pending stop is the only - // one carrying Accept / Reject buttons and a tick box, and the footer - // counts them out loud. Those are stronger signals, and they work in - // sunlight and for a colour-blind rider. - Expanded( - child: Opacity( - opacity: rejected ? 0.55 : 1.0, - child: Container( - margin: EdgeInsets.only(bottom: 8.h), - // The medium step of the inner-padding scale: 18 on the shift - // card, 16 here, 10 on the small stat cards. Was 11, which was - // tighter than anything else on the page and read as cramped - // directly under a card using 18. - padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h), - // ── Fill, not an edge ── - // - // No border and no shadow: the surface itself separates the card - // from the page, which is how most large apps draw a list card. - // A 1.5px border plus a drop shadow plus a fill was three - // devices doing one job, and the border was the one competing - // with the content — six of them stacked down the screen read as - // a form, not a route. - // - // The fill has to be strong enough to work alone; see - // [ColorConstants.cardSurface] for why it sits 9.4% off the page - // and not the ~5% a "subtle" grey would give. - decoration: BoxDecoration( - color: ColorConstants.cardSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), - // ── The whole card opens the stop ── - // - // The tap used to live on the name-and-address block alone, and - // it meant two different things: on a pending stop it toggled - // selection, and the detail sheet was hidden behind a - // long-press. That is a gesture nobody discovers, on the exact - // stops the rider is deciding about — and it made the same tap - // do different things depending on state. - // - // Tap now always opens the sheet, anywhere on the card, matching - // the Bookings card. Selection keeps the tick box, which is the - // visible, explicit control for it and sits right there on the - // row. - // - // `HitTestBehavior.opaque` so the padding counts too. The tick - // box, the call button and the Accept / Reject pair keep their - // own taps — a child's gesture wins over an ancestor's. - child: PressScale( - onTap: onStopTap == null ? null : () => onStopTap!(stop), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Badge · state · time at the door · tick - Row( - children: [ - // ── Type, as a label ── - // - // Was a solid `kind.accent` pill with white text, so - // every card carried a saturated maroon / blue / indigo - // block as its loudest element — three brand-strength - // ── The leading group owns all the slack ── - // - // The chip used to be a `Flexible` sitting beside a - // `Spacer` — and a Spacer is an `Expanded`, so the two - // *shared* the free width equally and the chip was - // capped at roughly 23pt however short its label was. - // That is what rendered it as "P…", and no amount of - // shortening the word could have fixed it. - // - // One Expanded now takes the whole remainder and the - // chip sizes naturally inside it. The state chip beside - // it gives way when a stop carries both, because - // "Accepted" truncating still reads, while a truncated - // type chip says nothing at all. - Expanded( - child: Row( - children: [ - // Flexible, but now competing only with the state - // chip rather than with a Spacer — so on a pending - // stop, which is the common case and the one with - // no state chip, it is the sole child and takes - // its natural width. It only gives ground when a - // stop genuinely carries both marks. - Flexible( - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, - vertical: 4.h, - ), - decoration: BoxDecoration( - color: kind.chipColor, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - kind.icon, - size: 12.sp, - color: Colors.white, - ), - SizedBox(width: 5.w), - Flexible( - child: Text( - kind.chipLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.5, - color: Colors.white, - fontFamily: - FontConstants.fontFamily, - ), - ), - ), - ], - ), - ), - ), - if (state != StopState.pending) ...[ - SizedBox(width: 6.w), - Flexible(child: _StateChip(state: state)), - ], - ], - ), - ), - // Fixed width, right-aligned — the third column. - // - // "~5 min" is materially narrower than "~10 min", so - // left-to-right this text began in a different place on - // every card and dragged the whole trailing cluster with - // it. Reserving the widest case and right-aligning - // inside it puts the duration on a column too, so all - // three trailing elements run straight down the route. - SizedBox( - width: 54.w, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerRight, - child: Text( - '~${formatTripDuration(service)}', - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w700, - // Darker grey than the page uses: on the card's - // light fill, `secondaryText` measures 2.83:1. - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ), - // Calling ahead is the one action a rider takes from - // the LIST rather than at the door — "are you open?", - // "I'm five minutes away" — so it belongs on the row, - // not buried in the detail sheet. - SizedBox(width: 4.w), - // ── Two fixed columns, always ── - // - // Both controls used to be conditional children of this - // row — `if (phone.isNotEmpty)` and `if (pending)` — so - // whenever one was absent the other slid across to take - // its place. A stop with no phone number pushed its tick - // box 48dp right; an already-accepted stop pushed its - // call button 44dp right. Down a route of mixed states - // the two controls zig-zagged left and right from card - // to card, and a rider reaching for "the tick" had to - // find it again on every row. - // - // The slots are reserved whether or not they are filled, - // so both live on the same column the whole way down the - // list and a thumb can travel straight. - _TrailingSlot( - child: phone.isEmpty - ? null - : _RowIconButton( - icon: Icons.call_rounded, - semanticLabel: 'Call $name', - onTap: () => onCallStop?.call(stop), - ), - ), - _TrailingSlot( - child: (pending && onToggleSelect != null) - ? _SelectBox( - selected: selected, - // One accent for selection app-wide, not a - // different colour per stop kind. - accent: ColorConstants.primary, - label: 'Select stop ${index + 1}', - // Dead while this stop's acceptance is on - // the wire: un-ticking it there would leave - // the tick and the request disagreeing. - onTap: stopBusy - ? null - : () => onToggleSelect!(id), - ) - : null, - ), - ], - ), - SizedBox(height: 4.h), + // ── The next rung, only on the stop he is standing at ── + // + // The route the rider is meant to take is: navigate to the counter, tick + // the orders that come off it, and claim the rung once for the lot from + // the bar. So an *accepted* card offers the journey (see [StopCard]) and + // not the claim — a per-card "Arrived" beside a Navigate button is two + // answers to one question, and the wrong one is a wrong claim to the hub. + // + // An **arrived** stop keeps a card-level rung, because by then the batch + // question is settled: he is at the counter, and the sheet it opens acts + // on every order from that counter anyway ([_advanceStopWithSheet]). + final advance = state == StopState.active + ? (MilkRun.nextActionLabel(_rungOf(state)) == null + ? null + : 'Mark as picked') + : null; - // Identity. No gesture of its own any more — the card - // above handles the tap for the whole surface. - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16.5.sp, - fontWeight: FontWeight.w800, - height: 1.15, - letterSpacing: -0.3, - color: ColorConstants.slateText, - decoration: rejected - ? TextDecoration.lineThrough - : null, - fontFamily: FontConstants.fontFamily, - ), - ), - if (address.isNotEmpty) ...[ - SizedBox(height: 2.h), - // Two lines normally: a third line of street - // address never changes which gate the rider walks - // to, and a card whose height depends on how verbose - // a customer was makes the whole list ragged. The - // full text is one tap away in the detail sheet. - // - // See [kFullAddressTrial] for the switch that lifts - // both this cap and the tail-stripping above. - Text( - address, - maxLines: kFullAddressTrial - ? kFullAddressMaxLines - : 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - height: 1.35, - fontWeight: FontWeight.w600, - // Was `onSurfaceVariant`, the brown M3 leftover - // of the red seed. Slate matches the rest of the - // card and reads better on the fill. - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ], - ), - - SizedBox(height: 7.h), - // ── What happens here, as one line ── - // - // Was three tinted chips — blue "Deliver 1", pink - // "Collect 2", green "₹340" — which put up to three more - // coloured surfaces on a card that already had a tinted - // fill and a coloured badge. The words carry it: nobody - // needs a blue box to know that "Deliver" is a delivery. - // - // Cash keeps green, because money is the one item here that - // is a *state* the rider is accountable for rather than a - // label — and green means money everywhere else in the app. - _MetaLine( - deliver: kind.hasDelivery - ? (deliver > 0 ? deliver : 1) - : 0, - collect: kind.hasPickup - ? (collect > 0 ? collect : 1) - : 0, - cash: cash, - ), - - // ── Decision ── - // Compact and right-aligned. Full-width buttons on every - // stop made six identical green bars march down the card - // and read as the loudest thing on screen — louder than - // the addresses they belong to. Sized to their text, they - // sit where the eye finishes the row and stay out of the - // way until wanted. - // A finished stop needs no action row at all: the chip - // beside the badge already says "Completed", and repeating - // it in a button-shaped box read as a second, tappable - // claim. Only an ACTIVE stop keeps a row, because that one - // carries an instruction ("finish it in Bookings") rather - // than a status. - // - // ── No buttons on a stop row ── - // - // Every row used to carry its own Accept and Reject. Six - // stops meant twelve buttons on one screen, the same two - // words repeated down the page, and — because a decision - // fired the moment it was tapped — six separate wire calls - // and six chances to hit the wrong one at arm's length on - // a parked scooter. - // - // Choosing is now one gesture and committing is another: - // the rider **ticks** the stops he wants (the box on the - // meta row above) and accepts the lot from the floating - // bar. That is the standard list-multi-select idiom — - // Gmail, Files, Photos — and it means nothing leaves the - // phone until he presses one deliberate button. Stops he - // does not tick are simply not taken; there is no separate - // Reject to mis-tap. - // - // What is left on the row is a *statement* for stops that - // are no longer his to decide — accepted, being worked, or - // finished — which is [_LockedNote], never a control. - if (locked) ...[ - SizedBox(height: 9.h), - _LockedNote(state: state), - ], - - // ── The way back ── - // - // A rejected stop is on Home precisely because the rider - // might change his mind about it, so it keeps exactly one - // control — and it does not commit work. Undoing a - // rejection returns the stop to undecided, where it is - // ticked and accepted like any other. Nothing here accepts - // anything by itself; that is still the floating bar's - // single job. - // - // A *skipped* stop used to carry a Resume here. It is now - // on Activity, with the same control — see - // [_showsOnHome]. - if (rejected && onUnrejectStop != null) - _rowAction( - label: 'Undo reject', - icon: Icons.undo_rounded, - color: ColorConstants.slateText, - busy: stopBusy, - onPressed: () => onUnrejectStop!(stop), - ), - ], - ), - ), - ), - ), - ), - ], - ), + return StopCard( + stop: stop, + // The route's own numbering, which does not renumber itself as stops + // leave the screen — see the filter note in [_sequence]. + index: index + 1, + state: state, + address: address, + service: roundTripDuration(Trip.serviceTimeFor(stop)), + bag: _bagOf(stop), + // The kitchen heading above already names where this came from. + groupedUnderSource: ServiceProfile.active.sourceIsKitchen, + selected: selectedIds.contains(id), + busy: stopBusy, + // Tap opens the sheet, anywhere on the card. Selection keeps the tick, + // which is the visible, explicit control for it. + onTap: onStopTap == null ? null : () => onStopTap!(stop), + onToggleSelect: (choosable && onToggleSelect != null) + ? () => onToggleSelect!(id) + : null, + // Calling ahead is the one action a rider takes from the LIST rather than + // at the door — "are you open?", "I'm five minutes away". + onCall: (phone.isEmpty || onCallStop == null) + ? null + : () => onCallStop!(stop), + // Hidden when the stop has no coordinates for the leg it is on: a + // Navigate that opens a map on nothing is worse than none, because the + // rider only finds out after committing to follow it. + onNavigate: + (onNavigateStop == null || + MilkRun.navigationTarget(stop) == null || + // Already offered, once, by the head above the list. + headIds.contains(id)) + ? null + : () => onNavigateStop!(stop), + // A rejected stop is on Home precisely because the rider might change his + // mind. Undoing returns it to undecided, where it is ticked and accepted + // like any other — nothing here accepts anything by itself. + onUnreject: onUnrejectStop == null ? null : () => onUnrejectStop!(stop), + // The way back into the stop he is already working. This used to be a red + // banner floating over the foot of the page. + // + // Offered only for a stop that is genuinely worked on its own screen. On + // a kitchen line a collection is not: its rungs are the slide sheet and + // the floating bar on this very page, so a Continue that opened the + // single-stop map/arrive/confirm flow was a second route up the same + // ladder — and the one that ends in a sheet asking him to confirm a + // delivery for a bag still on a counter. See [MilkRun.worksOnOwnScreen]. + onContinue: + (onContinueStop == null || + !MilkRun.worksOnOwnScreen(stop, collectedIds: collectedIds)) + ? null + : () => onContinueStop!(stop), + onAdvance: (advance == null || onAdvanceStop == null) + ? null + : () => onAdvanceStop!(stop), + advanceLabel: advance, ); } // `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See // [_MetaLine], which says the same three things on one uncoloured line. + /// Which bag this order travels in. + /// + /// Derived from its position in **its own pickup group**, not from the whole + /// route: the rider counts bags off one counter at a time, so the third order + /// from Kitchen 2 is *its* Bag 3 and not the route's ninth. A label the + /// kitchen actually printed always wins — see [BagManifest]. + String _bagOf(Map stop) { + if (!ServiceProfile.active.sourceIsKitchen) return stopBagLabel(stop); + final key = MilkRun.sourceKeyOf(stop); + final group = [ + for (final s in trip.stops) + if (MilkRun.sourceKeyOf(s) == key) s, + ]; + return BagManifest.bagFor(stop, group); + } + // ── Footer: accept remaining, or go work the trip ────────────────────── Widget _footerBar(List pendingIds) { final finished = trip.isFinished( @@ -1061,7 +1270,7 @@ class TripCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.verified_rounded, + LucideIcons.badgeCheck, size: 20.sp, color: ColorConstants.acceptGreen, ), @@ -1077,7 +1286,7 @@ class TripCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), @@ -1112,34 +1321,31 @@ class TripCard extends StatelessWidget { // select-all itself moved to the top — see [_selectAllRow]. return Padding( padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 10.h), - child: Text( - picked.isNotEmpty - ? 'Accept the stops you picked from the bar below' - : 'Tick the stops you want, then accept them below', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, + // Full width, or `textAlign` has nothing to align within: this sits in a + // `CrossAxisAlignment.start` column, so the Text shrink-wrapped to its + // own words and centring them inside their own box moved nothing. The + // caption has asked to be centred for as long as it has been left. + child: SizedBox( + width: double.infinity, + child: Text( + // Verb-first and half the length. A hint under a list is read at a + // glance or not at all; twelve words set as a sentence was furniture. + picked.isNotEmpty + ? 'Accept your picked stops below' + : 'Tick stops to accept them', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), ), ), ); } } -/// Rupee amounts without trailing noise: `1030`, `40`, `12.5`. -/// -/// Lifted out of [TripCard] so [_MetaLine] shares the one implementation rather -/// than the two drifting apart. -String _trim(double v) { - if (v >= 100) return v.round().toString(); - final rounded = (v * 10).round() / 10; - return rounded == rounded.roundToDouble() - ? rounded.round().toString() - : rounded.toStringAsFixed(1); -} - /// "MAP ›" on the START · HUB row. /// /// The label was "VIEW ROUTE", which spent three syllables and most of the row @@ -1172,24 +1378,20 @@ class _ViewRouteAction extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.map_rounded, - size: 14.sp, - color: ColorConstants.primary, - ), + Icon(LucideIcons.map, size: 14.sp, color: ColorConstants.primary), SizedBox(width: 5.w), Text( 'MAP', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), Icon( - Icons.chevron_right_rounded, + LucideIcons.chevronRight, size: 16.sp, color: ColorConstants.primary, ), @@ -1201,347 +1403,63 @@ class _ViewRouteAction extends StatelessWidget { } } -/// A trailing control slot on the meta row. +/// Hub / collection-centre accent, matching the Bookings route rail. +final Color _hubColor = ColorConstants.tertiary; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE LOADING ROUTE — the real timeline, in grey /// -/// Always [ButtonSizes.minTapTarget] wide, occupied or not, so the call button -/// and the tick box sit on the same two columns for every stop in the route — -/// see the note at the call site. An empty slot renders nothing but still holds -/// its width. -class _TrailingSlot extends StatelessWidget { - final Widget? child; - const _TrailingSlot({this.child}); - - @override - Widget build(BuildContext context) => - SizedBox(width: ButtonSizes.minTapTarget, child: child); -} - -/// Compact icon action that sits inline on a stop row. -class _RowIconButton extends StatelessWidget { - final IconData icon; - final String semanticLabel; - final VoidCallback onTap; - - const _RowIconButton({ - required this.icon, - required this.semanticLabel, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: semanticLabel, - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - // 44dp hit area around a 30px control — small on screen, never small - // under a thumb. - child: SizedBox( - width: ButtonSizes.minTapTarget, - height: ButtonSizes.minTapTarget, - child: Center( - child: Container( - // Same 28dp square as the tick box beside it. These were 30dp and - // 24dp, which read as uneven even once they were aligned. - width: 28.w, - height: 28.w, - alignment: Alignment.center, - // Green glyph on a white tile. - // - // White rather than the app's neutral grey because the card it - // sits on is grey, and a grey control on a grey card disappears — - // inner surfaces go lighter than their container, never the same - // shade. The green is on the icon and its edge, not the fill: a - // solid green tile would put a second green block on a card whose - // Accept button is already green. - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - border: Border.all( - color: ColorConstants.acceptGreen.withValues(alpha: 0.45), - width: 1.5, - ), - ), - child: Icon(icon, size: 16.sp, color: ColorConstants.acceptGreen), - ), - ), - ), - ), - ); - } -} - -/// Tick box for selecting a stop. -class _SelectBox extends StatelessWidget { - final bool selected; - final bool partial; - final Color accent; - final String label; - - /// Null while the stop's acceptance is in flight. - final VoidCallback? onTap; - - const _SelectBox({ - required this.selected, - required this.accent, - required this.label, - required this.onTap, - this.partial = false, - }); - - @override - Widget build(BuildContext context) { - final on = selected || partial; - return Semantics( - checked: selected, - label: label, - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - // 44dp hit area around a 22px box: the visible control stays compact - // but the target clears the accessibility floor. - child: Container( - width: ButtonSizes.minTapTarget, - height: ButtonSizes.minTapTarget, - alignment: Alignment.center, - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - // Matches the call button next to it — see the note there. - width: 28.w, - height: 28.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: on ? accent : ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - border: Border.all( - color: on ? accent : ColorConstants.borderStrong, - width: 2, - ), - ), - child: on - ? Icon( - partial ? Icons.remove_rounded : Icons.check_rounded, - size: 17.sp, - color: Colors.white, - ) - : null, - ), - ), - ), - ); - } -} - -/// Replaces the Accept/Reject pair once the decision has been made, keeping the -/// row in the same place on every card. +/// ── Why this is not the app's generic `SkeletonList` ── /// -/// It covers three states, and the wording is the difference between them: an -/// accepted stop is *taken* and waiting on Bookings, an active one is being -/// worked right now, a done one is finished. None of the three offers a control -/// — a stop the hub has already been told about is not re-decided from a list -/// row. (Accepting is undoable, but from the snackbar that follows the tap; see -/// the decision-row note in `_stopRow`.) -class _LockedNote extends StatelessWidget { - final StopState state; - const _LockedNote({required this.state}); - - @override - Widget build(BuildContext context) { - final done = state == StopState.done; - final accepted = state == StopState.accepted; - final color = done || accepted - ? ColorConstants.acceptGreen - : ColorConstants.primary; - - return Container( - height: ButtonSizes.secondary, - alignment: Alignment.center, - // White on the card's light fill, with a neutral edge; the state colour - // lives on the glyph and the words inside. A tinted panel with a tinted - // border was two coloured boxes deep for one sentence. - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(ButtonSizes.radius), - border: Border.all(color: ColorConstants.borderStrong, width: 1.5), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - done - ? Icons.verified_rounded - : accepted - ? Icons.check_circle_rounded - : Icons.directions_bike_rounded, - size: 17.sp, - color: color, - ), - SizedBox(width: 7.w), - // Flexible, because this row is as wide as its words and the column - // it sits in is narrower than the card: an unflexed Text here - // overflowed as soon as a state with a full sentence — accepted, or - // in-progress — appeared on a stop row. - Flexible( - child: Text( - done - ? 'Completed' - : accepted - ? 'Accepted — work it from Bookings' - : 'In progress — finish it in Bookings', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w800, - color: color, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], - ), - ); - } -} - -/// ── The work at this stop, on one line ── +/// Home used to load as three shimmering rounded rectangles, which is a +/// skeleton of *a list of cards* — a screen Home stopped being when the route +/// became a timeline. So the placeholder promised one layout and the arrival +/// delivered another, and every redesign of the route silently made the lie +/// bigger, because nothing links the two. /// -/// Replaces three tinted chips (`_tag`) with plain text. Uber's rule for a -/// figure that has to survive a glance is weight and contrast, not a container; -/// three containers on one row was the opposite of that, and on a combined stop -/// they wrapped onto a second line and made the card taller than its neighbours. +/// `skeletonizer` (2.1.3, MIT, 2.3k likes) removes the link problem rather than +/// fixing this instance of it: it takes the **real widget tree** — this real +/// [TripCard], built over placeholder stops — and paints its text and shapes as +/// bones. The loading state is therefore the actual node, spine, load line and +/// journey column, at the actual sizes, and it cannot drift from the screen it +/// is standing in for. Changing the timeline changes the skeleton. /// -/// Only the cash is coloured. Parcel counts are labels; the money is a state the -/// rider is accountable for, and green means money throughout the app. -class _MetaLine extends StatelessWidget { - final int deliver; - final int collect; - final double cash; - - const _MetaLine({ - required this.deliver, - required this.collect, - required this.cash, - }); - - @override - Widget build(BuildContext context) { - TextStyle style(Color c) => TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - color: c, - fontFamily: FontConstants.fontFamily, - ); - - final dot = TextSpan( - text: ' · ', - style: style(ColorConstants.borderStrong), - ); - - final spans = []; - if (deliver > 0) { - spans.add( - TextSpan( - text: 'Deliver $deliver', - style: style(ColorConstants.slateText), - ), - ); - } - if (collect > 0) { - if (spans.isNotEmpty) spans.add(dot); - spans.add( - TextSpan( - text: 'Collect $collect', - style: style(ColorConstants.slateText), - ), - ); - } - if (cash > 0) { - if (spans.isNotEmpty) spans.add(dot); - spans.add( - TextSpan( - text: '₹${_trim(cash)} cash', - // `acceptGreen` is a fill colour — 2.28:1 as text on the card fill. - style: style(ColorConstants.moneyGreen), - ), - ); - } - if (spans.isEmpty) return const SizedBox.shrink(); - - return Text.rich( - TextSpan(children: spans), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ); - } -} - -/// Small label naming a stop's state once the rider has decided. +/// It earns the dependency for the same reason: the alternative is a +/// hand-drawn second copy of a layout that is still moving. /// -/// The tinted pill behind it is gone. State is the one thing on this card that -/// legitimately earns colour, so the colour stays — on the icon and the word, -/// where it costs no surface. A pill around two words, inside a bordered card, -/// was a third box for a fact that reads fine as text. -class _StateChip extends StatelessWidget { - final StopState state; - const _StateChip({required this.state}); +/// The placeholder route is deliberately one kitchen with three orders — the +/// commonest shape — and the strings are never read: `Skeletonizer` replaces +/// every one of them with a bone before paint. +class HomeRouteSkeleton extends StatelessWidget { + const HomeRouteSkeleton({super.key}); - ({Color color, IconData icon}) get _look => switch (state) { - StopState.accepted => ( - color: ColorConstants.acceptGreen, - icon: Icons.check_circle_outline_rounded, - ), - StopState.active => ( - color: ColorConstants.primary, - icon: Icons.directions_bike_rounded, - ), - StopState.done => ( - color: ColorConstants.acceptGreen, - icon: Icons.verified_rounded, - ), - StopState.skipped => ( - color: ColorConstants.warning, - icon: Icons.replay_rounded, - ), - StopState.rejected => ( - color: ColorConstants.secondaryText, - icon: Icons.block_rounded, - ), - StopState.pending => ( - color: ColorConstants.secondaryText, - icon: Icons.schedule_rounded, - ), + static Map _stop(int i) => { + 'orderid': 'skeleton-$i', + 'type': 'pickup', + 'pickupcustomer': 'Loading customer', + 'pickupaddress': 'Loading address, Coimbatore', + 'kitchenid': 'skeleton-kitchen', + 'kitchenname': 'Loading kitchen', + 'Quantity': '1', }; @override Widget build(BuildContext context) { - final look = _look; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(look.icon, size: 12.sp, color: look.color), - SizedBox(width: 4.w), - Flexible( - child: Text( - state.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.6, - color: look.color, - fontFamily: FontConstants.fontFamily, - ), - ), + return Skeletonizer( + // The app's own shimmer feel: one soft sweep, not a strobe. A loading + // screen a rider sees forty times a shift is the last place for motion + // that asks to be watched. + effect: ShimmerEffect( + baseColor: ColorConstants.borderSubtle, + highlightColor: ColorConstants.neutralLight, + duration: const Duration(milliseconds: 1100), + ), + child: TripCard( + trip: Trip.fromStops( + id: 'skeleton', + stops: [for (var i = 0; i < 3; i++) _stop(i)], ), - ], + ).buildStops(), ); } } - -/// Hub / collection-centre accent, matching the Bookings route rail. -final Color _hubColor = ColorConstants.tertiary; diff --git a/lib/views/Dashboard/home/trip_empty_state.dart b/lib/views/Dashboard/home/trip_empty_state.dart index 0c6d0e3..76b0aa0 100644 --- a/lib/views/Dashboard/home/trip_empty_state.dart +++ b/lib/views/Dashboard/home/trip_empty_state.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; -import 'package:miler/views/helpers/constants/design_constants.dart'; -import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -29,7 +28,7 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart'; /// is coming until he goes on — so there the motion stops and the colour /// drains to grey. /// ───────────────────────────────────────────────────────────────────────── -class TripEmptyState extends StatefulWidget { +class TripEmptyState extends StatelessWidget { /// `Trip 2` — the tab this slot belongs to, named the same way the tabs, /// the hub and his supervisor name it. final String tripLabel; @@ -48,38 +47,9 @@ class TripEmptyState extends StatefulWidget { this.onGoOnDuty, }); - @override - State createState() => _TripEmptyStateState(); -} - -class _TripEmptyStateState extends State - with SingleTickerProviderStateMixin { - /// Slow enough to read as breathing rather than blinking. - /// - /// 2s out and 2s back. The pulse on the live-pickup banner runs at 1.1s - /// because it marks a job happening *now*; this one marks a wait, and a wait - /// that flashes at you is an alarm. - /// - /// Initialised lazily on the declaration rather than in `initState` so hot - /// reload cannot leave it unset — see the note in `Bottom_page.dart`. - late final AnimationController _breath = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2000), - )..repeat(reverse: true); - - @override - void dispose() { - _breath.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final offline = widget.offline; - - // Grey off duty, brand red waiting. The halo and the chip move together on - // this one colour, so the screen carries a single decision rather than - // three. + // Grey off duty, brand red waiting. final Color accent = offline ? ColorConstants.secondaryText : ColorConstants.primary; @@ -99,164 +69,98 @@ class _TripEmptyStateState extends State // and adding to that squeezed the chip below on a narrow phone. padding: EdgeInsets.fromLTRB(0, 36.h, 0, 40.h), child: MilerEmptyState( - // The component draws its own static halo from `icon`; this one is - // supplied as the illustration instead so it can breathe. The icon is - // still passed for the fallback path. - icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded, - illustration: _BreathingHalo( - breath: _breath, - accent: accent, - icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded, - // Off duty is a settled state, not a wait — so it holds still. - animate: !offline, - ), - title: offline - ? 'You are off duty' - : '${widget.tripLabel} not assigned yet', + // The component draws its own static halo from `icon`; the offline + // state supplies the breathing one instead. The icon is still passed + // for the fallback path. + icon: offline ? LucideIcons.wifiOff : LucideIcons.route, + // ── Waiting for a trip is a picture, not a diagram ── + // + // The unassigned slot wore a route glyph inside two breathing rings, + // and under it two sentences plus a "Waiting for your hub" chip — + // three pieces of furniture explaining an absence. The artwork says + // the whole thing in one look (a rider sitting on his scooter beside + // a map with a question mark on it), so the screen is the picture and + // one line. Off duty keeps the halo: it is a *state*, not a wait, and + // it still has a button under it worth pointing at. + illustration: offline + ? _Halo(accent: accent, icon: LucideIcons.wifiOff) + : Image.asset( + 'assets/images/trip_not_assigned.png', + fit: BoxFit.contain, + // A missing asset degrades to nothing, never to the grey + // broken-image box in the middle of an empty screen. + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + illustrationSize: offline ? 180 : 208.w, + title: offline ? 'You are off duty' : 'Trip not assigned yet', message: offline ? 'Go on duty to start receiving trips from your hub.' - : 'Your hub assigns trips as customer slots fill up. This one ' - 'appears here the moment it is ready.', + : null, accent: offline ? cta : accent, actionLabel: offline ? 'Go on duty' : null, - onAction: offline ? widget.onGoOnDuty : null, - // The chip goes under the copy, where a CTA would be if there were one - // to offer. On duty there is nothing for the rider to do but wait, and - // the honest screen says so rather than inventing a button. - footer: offline - ? null - : _WaitingChip(breath: _breath, accent: accent), + onAction: offline ? onGoOnDuty : null, ), ); } } -/// The icon on two soft rings that swell and settle together. -class _BreathingHalo extends StatelessWidget { - final Animation breath; +/// The icon on two soft rings. +/// +/// It used to breathe — the rings swelled and settled on a 4s cycle to say +/// "still waiting" on an unassigned trip. That screen is an illustration now, +/// so the only caller left is the off-duty state, which is a settled state +/// rather than a wait and always held still. A controller repeating forever to +/// drive a value multiplied by zero is a rebuild per frame for nothing, so the +/// animation is gone and this widget — and its host — are stateless again. +class _Halo extends StatelessWidget { final Color accent; final IconData icon; - final bool animate; - const _BreathingHalo({ - required this.breath, - required this.accent, - required this.icon, - required this.animate, - }); + const _Halo({required this.accent, required this.icon}); @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: breath, - builder: (context, _) { - // 0 → 1 with the ends eased, so the halo pauses at full breath instead - // of bouncing off it. - final t = animate ? Curves.easeInOut.transform(breath.value) : 0.0; - - return Center( - child: Stack( - alignment: Alignment.center, - children: [ - // Outer ring: the widest travel and the faintest tint, so what - // the eye catches is movement rather than a pulsing disc. - Container( - width: 132.w + 16.w * t, - height: 132.w + 16.w * t, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.05 * (1 - 0.35 * t)), - shape: BoxShape.circle, - ), - ), - Container( - width: 96.w + 8.w * t, - height: 96.w + 8.w * t, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.09), - shape: BoxShape.circle, - ), - ), - // The disc holding the glyph stays put. Something has to be - // still or the whole group reads as drifting. - // - // Its shadow is a soft even lift rather than a drop: offset - // downward it cast a dark crescent across the ring underneath and - // the three circles read as one muddy shape. - Container( - width: 64.w, - height: 64.w, - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: accent.withValues(alpha: 0.06), - blurRadius: 14, - spreadRadius: 1, - ), - ], - ), - child: Icon(icon, size: 28.sp, color: accent), - ), - ], - ), - ); - }, - ); - } -} - -/// `● Waiting for your hub` — the status line, as a pill. -/// -/// It is what turns the screen from a dead end into a state: the rider is not -/// being told he has nothing, he is being told the app is still listening. -class _WaitingChip extends StatelessWidget { - final Animation breath; - final Color accent; - - const _WaitingChip({required this.breath, required this.accent}); - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.07), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + return Center( + child: Stack( + alignment: Alignment.center, children: [ - AnimatedBuilder( - animation: breath, - builder: (context, _) { - final t = Curves.easeInOut.transform(breath.value); - return Container( - width: 7.w, - height: 7.w, - decoration: BoxDecoration( - // Never fully out: a dot that disappears reads as a fault, - // not a heartbeat. - color: accent.withValues(alpha: 0.45 + 0.55 * t), - shape: BoxShape.circle, - ), - ); - }, - ), - SizedBox(width: 8.w), - // Flexible, so a rider on the largest system font size gets a - // narrower chip rather than a clipped one. - Flexible( - child: Text( - 'Waiting for your hub', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: MilerType.micro.copyWith( - color: accent, - fontWeight: FontWeight.w700, - ), + // Outer ring: the widest and the faintest, so the shape reads as a + // halo rather than as a stack of discs. + Container( + width: 132.w, + height: 132.w, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.05), + shape: BoxShape.circle, ), ), + Container( + width: 96.w, + height: 96.w, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.09), + shape: BoxShape.circle, + ), + ), + // The glyph's own disc. Its shadow is spread evenly rather than cast + // downward: offset down, it threw a dark crescent across the ring + // underneath and the three circles read as one muddy shape. + Container( + width: 64.w, + height: 64.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.06), + blurRadius: 14, + spreadRadius: 1, + ), + ], + ), + child: Icon(icon, size: 28.sp, color: accent), + ), ], ), ); diff --git a/lib/views/Dashboard/home/trip_progress_rail.dart b/lib/views/Dashboard/home/trip_progress_rail.dart index 871b2b1..9cb6c46 100644 --- a/lib/views/Dashboard/home/trip_progress_rail.dart +++ b/lib/views/Dashboard/home/trip_progress_rail.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; @@ -150,7 +152,7 @@ class _TripProgressRailState extends State { final double viewport = _scroll.position.viewportDimension; final double target = - ((at - 1) * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2); + (at * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2); final double clamped = target.clamp( 0.0, _scroll.position.maxScrollExtent, @@ -207,17 +209,41 @@ class _TripProgressRailState extends State { /// and every stop keeps its number underneath. Widget _rail() { final total = progress.length; + final allDoneNow = _doneCount == total; return LayoutBuilder( builder: (context, constraints) { final double available = constraints.maxWidth; - final double gap = _gapFor(available, total); - final double content = (total * _node.w) + ((total - 1) * gap); + final double gap = _gapFor(available, total + 2); + // Two framing nodes and their two connectors ride with the stops. + final double content = ((total + 2) * _node.w) + ((total + 1) * gap); // +0.5 so a route that lands exactly on the width is not tipped into a // scroll view by float error. final bool fits = content <= available + 0.5; - final children = []; + final children = [ + // ── A rail with one stop on it was a badge ── + // + // A rider with a single accepted order met one red circled **1** at + // the top of his Deliveries page — a counter, or a notification + // badge, but not a route. Nothing about it said *journey*, because a + // journey needs somewhere to start. + // + // So the run is framed by the two places it actually runs between: + // where he is standing now, and where the day ends. The stops sit + // between them, and even one of them now reads as + // "here → this pickup → done" rather than as the number one. + _EndNode( + icon: LucideIcons.locateFixed, + color: ColorConstants.primary, + ), + _connector( + progress.first == StopProgress.pending + ? StopProgress.pending + : StopProgress.done, + width: gap, + ), + ]; for (var i = 0; i < total; i++) { if (i > 0) { // A connector is coloured by the stop *behind* it, so the green @@ -227,6 +253,20 @@ class _TripProgressRailState extends State { } children.add(_dot(i)); } + // Where the day ends: a depot on a logistics loop, home on a round that + // finishes at the last door. Capability, never screen position — see + // [ServiceProfile.endsAtHub]. + children.add(_connector(progress.last, width: gap)); + children.add( + _EndNode( + icon: ServiceProfile.active.endsAtHub + ? LucideIcons.warehouse + : LucideIcons.house, + color: allDoneNow + ? ColorConstants.acceptGreen + : ColorConstants.borderStrong, + ), + ); final int? at = _currentPosition; @@ -249,9 +289,7 @@ class _TripProgressRailState extends State { // Node centre, less half the glyph, so the scooter sits over // the node rather than beside it. left: - ((at - 1) * (_node.w + gap)) + - (_node.w / 2) - - (_scooter.w / 2), + (at * (_node.w + gap)) + (_node.w / 2) - (_scooter.w / 2), child: _scooterGlyph(), ), ], @@ -260,13 +298,54 @@ class _TripProgressRailState extends State { if (fits) return layers; + // ── A scrolling rail has to say it scrolls ── + // + // A 21-stop round draws about seven nodes on a 390pt phone, and the + // eighth was sliced clean down its middle by the viewport edge — a + // half circle and a connector stopping in mid air, which reads as a + // clipping bug rather than as "there is more route this way". The + // rider's own count is off-screen for most of the day, so the one + // thing this edge has to do is invite the push that reveals it. + // + // A fade, not a chevron or a shadow: the content *continues*, it is + // not covered by something, and the app spends its edges on the + // route rather than on furniture pointing at it. + // + // The stops are `daylightSurface` — this widget's own ground on both + // tabs that draw it — so the ramp dissolves into the page instead of + // laying a grey band over it. Only the scrolling branch is masked; a + // short route ends where the route ends and has nothing to hint at. + // + // `dstIn` multiplies the layer's alpha by the gradient's, so the + // nodes fade rather than being painted over — the mask has to be + // sampled over the whole box for that, hence `bounds` unmodified. + const double fade = 24; return SizedBox( height: _railHeight, - child: SingleChildScrollView( - controller: _scroll, - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: layers, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: const [ + Color(0x00FFFFFF), + Color(0xFFFFFFFF), + Color(0xFFFFFFFF), + Color(0x00FFFFFF), + ], + stops: [ + 0.0, + (fade / bounds.width).clamp(0.0, 0.5), + 1 - (fade / bounds.width).clamp(0.0, 0.5), + 1.0, + ], + ).createShader(bounds), + child: SingleChildScrollView( + controller: _scroll, + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: layers, + ), ), ); }, @@ -279,11 +358,7 @@ class _TripProgressRailState extends State { return SizedBox( width: _scooter.w, height: _scooter.w, - child: Icon( - Icons.two_wheeler_rounded, - size: 20.sp, - color: ColorConstants.primary, - ), + child: Icon(LucideIcons.bike, size: 20.sp, color: ColorConstants.primary), ); } @@ -329,7 +404,7 @@ class _TripProgressRailState extends State { // Fixed against the box: the circle does not grow with the system // font scale, so the digits inside it cannot either. fontSize: 14.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, height: 1, color: ink, @@ -407,7 +482,7 @@ class _TripProgressRailState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -419,7 +494,7 @@ class _TripProgressRailState extends State { allDone ? 'All stops done' : '$done of $total done', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: allDone ? ColorConstants.acceptGreen @@ -455,13 +530,18 @@ class _TripProgressRailState extends State { // whole line ellipsises as a unit if the route label is long. Text( allDone - ? 'Head back to the hub' + // Where the day ends follows the capability, not the screen. + // A milk run finishes at the last door; there is no depot at + // the end of it and nothing to carry back to one. + ? (ServiceProfile.active.endsAtHub + ? 'Head back to the hub' + : 'Round complete') : _footStatus(total: total, done: done), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: allDone ? ColorConstants.acceptGreen : ColorConstants.secondaryText, @@ -481,11 +561,48 @@ class _TripProgressRailState extends State { if (at != null) 'On stop $at of $total' else '${total - done} to go', if (skipped > 0) '$skipped skipped', if (left != null && left > Duration.zero) - '${formatTripDuration(roundTripDuration(left))} left, then hub', + // ── "then hub" is a parcel sentence ── + // + // A logistics day is a loop: leave the hub empty, fill up at customer + // doors, return with the consignments. A meal rider finishes at the + // last subscriber's door and goes home — telling him his round ends at + // a warehouse he has never visited is a journey nobody is making, and + // the same fiction the RETURN · HUB row was removed from Home for. + ServiceProfile.active.sourceIsKitchen + ? '${formatTripDuration(roundTripDuration(left))} left' + : '${formatTripDuration(roundTripDuration(left))} left, then hub', ]; return parts.join(' · '); } } +/// One end of the run: where the rider is standing, or where his day finishes. +/// +/// Deliberately not a numbered node — these are not stops he works, they are +/// the two ends the stops sit between. Same footprint as a stop node so the +/// rail stays on one axis, hollow so a place he does not *do* anything at never +/// competes with one he does. +class _EndNode extends StatelessWidget { + final IconData icon; + final Color color; + + const _EndNode({required this.icon, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 34.w, + height: 34.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + border: Border.all(color: color, width: 2), + ), + child: Icon(icon, size: 15.sp, color: color), + ); + } +} + /// Hub accent, shared with the Home trip card. final Color kRailHubColor = ColorConstants.tertiary; diff --git a/lib/views/Dashboard/home/trip_tabs.dart b/lib/views/Dashboard/home/trip_tabs.dart index 1f20adf..b2f16d8 100644 --- a/lib/views/Dashboard/home/trip_tabs.dart +++ b/lib/views/Dashboard/home/trip_tabs.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; @@ -159,7 +161,12 @@ class _TripTabsState extends State /// The colour a segment settles on once the pill is over it. Color _litColourOf(int tripIndex) { - if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText; + if (widget.trips.tripAt(tripIndex) == null) { + // Selected-but-empty keeps a readable voice — the rider deliberately + // looked at this slot, so it answers — but never the brand: there is + // nothing here to be mission about. + return ColorConstants.secondaryText; + } return _percentOf(tripIndex) >= 100 ? ColorConstants.acceptGreen : ColorConstants.primary; @@ -171,16 +178,37 @@ class _TripTabsState extends State /// Done" is worth knowing from Trip 2 — so for those the two colours are the /// same and nothing changes as the pill goes by. Color _restColourOf(int tripIndex) { - if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText; + if (widget.trips.tripAt(tripIndex) == null) { + // ── An empty slot is furniture, not a peer ── + // + // At `secondaryText` an unassigned tab was indistinguishable from a + // real unselected trip, so a morning screen read as three equal trips + // when two of them were placeholders. The disabled token is what the + // app already means by "nothing here to act on", and it is exactly one + // voice quieter — the slot is still legible, it has simply stopped + // competing. + return ColorConstants.disabledFill; + } return _percentOf(tripIndex) >= 100 ? ColorConstants.acceptGreen : ColorConstants.secondaryText; } - int _percentOf(int tripIndex) => widget.trips[tripIndex].completionPercent( - acceptedIds: widget.acceptedIds, - rejectedIds: widget.rejectedIds, - ); + /// ── Slot, not position ── + /// + /// Every lookup here goes through [TripSlots.tripAt]. Tab 1 is the morning + /// whether or not the rider has morning work, so `trips[i]` — which meant + /// "the i-th trip he happens to have" — would put an afternoon run under the + /// Trip 1 tab on a day with no morning stops, and the rider's Trip 2 would + /// not be the hub's. + int _percentOf(int tripIndex) => + widget.trips + .tripAt(tripIndex) + ?.completionPercent( + acceptedIds: widget.acceptedIds, + rejectedIds: widget.rejectedIds, + ) ?? + 0; /// True when this trip has a stop in progress — the rider is on it now. /// @@ -231,7 +259,10 @@ class _TripTabsState extends State margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h), padding: EdgeInsets.all(3.w), decoration: BoxDecoration( - color: ColorConstants.neutralLight, + // Layer 1 on the canvas, same as every working surface on Home. + // `neutralLight` separated from the old cream backing by 1.02:1 — + // a track you had to be told about. + color: MilerSurface.working, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), // LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder @@ -286,7 +317,9 @@ class _TripTabsState extends State // tint, so the two controls a rider uses most looked // like they came from different apps. color: ColorConstants.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), ), child: Align( alignment: Alignment.bottomCenter, @@ -304,7 +337,9 @@ class _TripTabsState extends State ), decoration: BoxDecoration( color: markColour, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), ), ), ), @@ -334,14 +369,13 @@ class _TripTabsState extends State return Expanded( child: _Tab( label: Trip.tabLabel(i), - percent: i < widget.trips.length - ? _percentOf(i) - : 0, - assigned: i < widget.trips.length, + percent: _percentOf(i), + assigned: widget.trips.tripAt(i) != null, selected: i == widget.selectedIndex, - active: - i < widget.trips.length && - _isRunning(widget.trips[i]), + active: switch (widget.trips.tripAt(i)) { + final t? => _isRunning(t), + _ => false, + }, lit: lit, restColour: _restColourOf(i), litColour: _litColourOf(i), @@ -479,8 +513,14 @@ class _PinnedSurface extends StatelessWidget { return AnimatedContainer( duration: const Duration(milliseconds: 160), decoration: BoxDecoration( - // Opaque, or the stop cards show through the bar as they pass under it. - color: ColorConstants.surface, + // Opaque, or the stop cards show through the bar as they pass under + // it. And it is the CANVAS, not `ColorConstants.surface` — that token + // is the warm near-white the surface audit flagged as "warm, unlike + // everything else", and here it painted a cream band across the top + // of a cool grey page. The bar is the page holding its position, so + // it wears the page's own ground; the white segmented track stands on + // it at the full canvas step. + color: MilerSurface.canvas, border: Border( bottom: BorderSide( color: floating ? ColorConstants.borderStrong : Colors.transparent, @@ -552,7 +592,10 @@ class _Tab extends StatelessWidget { final Color accent = Color.lerp(restColour, litColour, lit)!; final String status = !assigned - ? 'Not set' + // "Not set" read as a chore waiting for the rider. The hub fills + // these slots; all this tab can honestly say is that nothing has + // arrived for it yet. + ? 'None yet' : complete ? 'Done' : active @@ -581,7 +624,9 @@ class _Tab extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w800, + // A placeholder slot also gives up the headline weight — + // colour alone was not enough separation on a sunlit LCD. + fontWeight: assigned ? FontWeight.w700 : FontWeight.w600, letterSpacing: -0.3, color: accent, fontFamily: FontConstants.fontFamily, @@ -598,7 +643,7 @@ class _Tab extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: accent, fontFamily: FontConstants.fontFamily, ), @@ -658,17 +703,17 @@ class _OverflowMenu extends StatelessWidget { Trip.tabLabel(i), style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(width: 8.w), Text( - i < trips.length ? trips[i].slotLabel : 'Not assigned', + trips.tripAt(i)?.slotLabel ?? 'Not assigned', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -684,7 +729,7 @@ class _OverflowMenu extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.expand_more_rounded, + LucideIcons.chevronDown, size: 22.sp, color: ColorConstants.slateText, ), @@ -692,7 +737,7 @@ class _OverflowMenu extends StatelessWidget { '+${hidden.length}', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/orders/orderstaus_button.dart b/lib/views/Dashboard/orders/orderstaus_button.dart index fd339c4..8d2dfd1 100644 --- a/lib/views/Dashboard/orders/orderstaus_button.dart +++ b/lib/views/Dashboard/orders/orderstaus_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'dart:async'; import 'package:miler/views/helpers/constants/Font_constant.dart'; @@ -6,7 +7,8 @@ import 'package:slide_to_submit_button/slide_to_submit_button.dart'; import 'package:image_picker/image_picker.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; -import 'package:miler/views/helpers/widgets/page_transitions.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; +import 'package:miler/data/service_profile.dart'; /// ------------------------------ /// MAIN WIDGET WITH TWO BUTTONS @@ -118,12 +120,15 @@ class _OrderStatusRowState extends State { // REJECT SHEET // ------------------------------ void _showRejectSheet(BuildContext context) { - showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + // Through the kit. Two things changed beyond the fabric: + // + // • The Close IconButton is gone — the handle and the barrier already + // dismiss, and a sheet's title row is not a toolbar. + // • A selected reason is a soft red field with a tick, not a solid red + // slab. Filled-primary selection made four buttons compete with the + // one destructive CTA under them, and colour was its only channel. + showMilerSheet( + context, builder: (context) { String selectedReason = ""; bool isLoading = false; @@ -134,100 +139,51 @@ class _OrderStatusRowState extends State { "Other reason", ]; - return SafeArea( - top: false, - left: false, - right: false, - bottom: true, - child: StatefulBuilder( - builder: (context, setModalState) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Reject booking", - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - fontFamily: FontConstants.fontFamily, - ), - ), - IconButton( - tooltip: 'Close', - icon: Icon( - Icons.close_rounded, - color: ColorConstants.onSurfaceVariant, - ), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 10), - for (String reason in reasons) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: GestureDetector( - onTap: () => setModalState(() { - selectedReason = reason; - }), - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - height: ButtonSizes.primary, - width: double.infinity, - decoration: BoxDecoration( - color: selectedReason == reason - ? ColorConstants.primary - : ColorConstants.neutralLight, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: selectedReason == reason - ? ColorConstants.primary - : ColorConstants.dividerColor, - ), - ), - alignment: Alignment.center, - child: Text( - reason, - style: TextStyle( - color: selectedReason == reason - ? Colors.white - : ColorConstants.onSurface, - fontWeight: FontWeight.w600, - fontFamily: FontConstants.fontFamily, - fontSize: 15, - ), - ), - ), - ), + return StatefulBuilder( + builder: (context, setModalState) { + return MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const MilerSheetHeader( + title: 'Reject booking', + subtitle: 'The hub re-routes it once it knows why.', + ), + const SizedBox(height: 14), + for (String reason in reasons) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: MilerSheetChoiceRow( + label: reason, + selected: selectedReason == reason, + onTap: () => setModalState(() { + selectedReason = reason; + }), ), - const SizedBox(height: 20), - MilerButton( - label: "Reject booking", - color: ColorConstants.error, - loading: isLoading, - onPressed: selectedReason.isEmpty - ? null - : () async { - setModalState(() => isLoading = true); - final success = await _onStatusChanged( - "REJECTED", - notes: selectedReason, - ); - if (context.mounted) { - Navigator.pop(context, success); - } - }, ), - ], - ), - ); - }, - ), + const SizedBox(height: 10), + MilerButton( + label: "Reject booking", + color: ColorConstants.error, + loading: isLoading, + onPressed: selectedReason.isEmpty + ? null + : () async { + setModalState(() => isLoading = true); + final success = await _onStatusChanged( + "REJECTED", + notes: selectedReason, + ); + if (context.mounted) { + Navigator.pop(context, success); + } + }, + ), + ], + ), + ); + }, ); }, ); @@ -237,87 +193,68 @@ class _OrderStatusRowState extends State { // CANCEL SHEET // ------------------------------ void _showCancelSheet(BuildContext context) { - showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + showMilerSheet( + context, builder: (context) { bool confirmCancel = false; bool isLoading = false; - return SafeArea( - top: false, - left: false, - right: false, - bottom: true, - child: StatefulBuilder( - builder: (context, setModalState) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "Cancel this Booking?", - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - fontFamily: FontConstants.fontFamily, - ), - ), - const SizedBox(height: 10), - const Text( - "Once cancelled, this Booking will return to pending state.", - textAlign: TextAlign.center, - ), - const SizedBox(height: 25), - CheckboxListTile( - value: confirmCancel, - onChanged: (value) => - setModalState(() => confirmCancel = value ?? false), - title: const Text("I confirm to cancel this Booking"), - controlAffinity: ListTileControlAffinity.leading, - ), - const SizedBox(height: 20), - MilerButton( - label: "Confirm cancel", - color: ColorConstants.warning, - loading: isLoading, - onPressed: !confirmCancel - ? null - : () async { - setModalState(() => isLoading = true); - // Capture the messenger BEFORE popping — using the - // sheet's context after pop looks up a deactivated - // widget and can crash. - final messenger = ScaffoldMessenger.of(context); - // Actually cancel via the real status-change path - // instead of just showing a fake success snackbar. - final success = await _onStatusChanged( - "CANCELLED", - notes: "Cancelled by rider", - ); - if (context.mounted) { - Navigator.pop(context, success); - } - messenger.showSnackBar( - SnackBar( - content: Text( - success - ? "Booking cancelled" - : "Couldn't cancel. Please try again.", - ), + return StatefulBuilder( + builder: (context, setModalState) { + return MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const MilerSheetHeader( + title: 'Cancel this booking?', + subtitle: 'It returns to pending and the hub reassigns it.', + ), + const SizedBox(height: 14), + CheckboxListTile( + value: confirmCancel, + onChanged: (value) => + setModalState(() => confirmCancel = value ?? false), + title: const Text("I confirm to cancel this Booking"), + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 20), + MilerButton( + label: "Confirm cancel", + color: ColorConstants.warning, + loading: isLoading, + onPressed: !confirmCancel + ? null + : () async { + setModalState(() => isLoading = true); + // Capture the messenger BEFORE popping — using the + // sheet's context after pop looks up a deactivated + // widget and can crash. + final messenger = ScaffoldMessenger.of(context); + // Actually cancel via the real status-change path + // instead of just showing a fake success snackbar. + final success = await _onStatusChanged( + "CANCELLED", + notes: "Cancelled by rider", + ); + if (context.mounted) { + Navigator.pop(context, success); + } + messenger.showSnackBar( + SnackBar( + content: Text( + success + ? "Booking cancelled" + : "Couldn't cancel. Please try again.", ), - ); - }, - ), - ], - ), - ); - }, - ), + ), + ); + }, + ), + ], + ), + ); + }, ); }, ); @@ -341,7 +278,7 @@ class _OrderStatusRowState extends State { children: [ if (widget.onCall != null) ...[ _IconAction( - icon: Icons.phone_rounded, + icon: LucideIcons.phone, color: ColorConstants.acceptGreen, tooltip: 'Call customer', onTap: widget.onCall, @@ -350,7 +287,7 @@ class _OrderStatusRowState extends State { ], if (widget.onDetails != null) ...[ _IconAction( - icon: Icons.receipt_long_rounded, + icon: LucideIcons.receiptText, color: ColorConstants.onSurfaceVariant, tooltip: 'Booking details', onTap: widget.onDetails, @@ -360,7 +297,7 @@ class _OrderStatusRowState extends State { // LEFT PRIMARY (Reject / Cancel) — compact tonal destructive _IconAction( - icon: showCancel ? Icons.undo_rounded : Icons.close_rounded, + icon: showCancel ? LucideIcons.undo2 : LucideIcons.x, color: showCancel ? ColorConstants.warning : ColorConstants.error, @@ -391,7 +328,9 @@ class _OrderStatusRowState extends State { child: Container( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), ), child: Center( child: Container( @@ -492,7 +431,7 @@ class StatusPill extends StatelessWidget { style: TextStyle( fontSize: 14.5, color: fg, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: 0.3, fontFamily: FontConstants.fontFamily, ), @@ -585,13 +524,13 @@ class _OrderStatusButtonState extends State { IconData _iconForStatus() { switch (_buttonText) { case "ARRIVED": - return Icons.inventory_2_rounded; + return LucideIcons.package; case "PICKED": - return Icons.task_alt_rounded; + return LucideIcons.squareCheckBig; case "ACCEPTED": - return Icons.directions_bike_rounded; + return LucideIcons.bike; default: - return Icons.check_rounded; + return LucideIcons.check; } } @@ -619,7 +558,14 @@ class _OrderStatusButtonState extends State { case "ARRIVED": return "Arrived"; case "PICKED": - return "Pick Up"; + // The last rung is the same API call either way, but it is not the same + // act. On a parcel route the rider is taking something from a customer; + // on a service route he is giving something to one, and a slider + // reading "Pick Up" at a subscriber's door describes the opposite of + // what he is about to do. + return ServiceProfile.active.handsOffAtCollection + ? "Handed over" + : "Pick Up"; default: return _nextStatus ?? ""; } @@ -634,7 +580,12 @@ class _OrderStatusButtonState extends State { if (next == null || _advancing) return; _advancing = true; try { - if (next == "PICKED") { + // The camera on the last rung is proof a parcel changed hands. A tenant + // whose riders drop pre-paid subscription meals does not photograph + // lunch boxes, so the step is skipped rather than made optional — an + // optional camera on a ten-drop run is ten dismissals. + // See [ServiceProfile.needsProofPhoto]. + if (next == "PICKED" && ServiceProfile.active.needsProofPhoto) { final XFile? photo = await ImagePicker().pickImage( source: ImageSource.camera, imageQuality: 50, @@ -707,11 +658,7 @@ class _OrderStatusButtonState extends State { ), ], ), - child: Icon( - Icons.arrow_forward_rounded, - size: 18, - color: color, - ), + child: Icon(LucideIcons.arrowRight, size: 18, color: color), ), ), hint: Align( @@ -722,7 +669,7 @@ class _OrderStatusButtonState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, letterSpacing: 0.2, color: color, diff --git a/lib/views/Dashboard/pickups/card.dart b/lib/views/Dashboard/pickups/card.dart index c0e642a..4773651 100644 --- a/lib/views/Dashboard/pickups/card.dart +++ b/lib/views/Dashboard/pickups/card.dart @@ -36,6 +36,29 @@ class PickupCard extends StatelessWidget { final bool enabled; final bool isSkipped; + /// The bag this order travels in, as recorded at the counter. Empty on a + /// parcel route, where there is no load to match against. See [BagManifest]. + final String bag; + + /// Orders the rider is carrying. + /// + /// Needed because "picked up" is not a *status* on this screen — the accepted + /// store normalises these rows to `accepted`, and what actually records the + /// hand-over is the collected set. Without it, [MilkRun.workingKind] reads a + /// bag already in the rider's box as still needing collection, and the button + /// says **Start Pickup** for a delivery. + final Set collectedIds; + + /// Orders whose delivery leg the rider has actually started. + /// + /// Distinct from [collectedIds] on purpose, and the distinction is the whole + /// point of the chip: a bag in his box is **PICKED**, a round he has set off + /// on is **ACTIVE**. The backend cannot answer this — `pickup-complete` + /// stamps a hyperlocal consignment `Out_for_Delivery` at the counter — so the + /// rung is resolved through [MilkRun.stageOf] against this set rather than + /// off the row's raw status. + final Set outForDeliveryIds; + const PickupCard({ super.key, required this.item, @@ -44,6 +67,9 @@ class PickupCard extends StatelessWidget { this.riderSpeedMps, this.enabled = true, this.isSkipped = false, + this.bag = '', + this.collectedIds = const {}, + this.outForDeliveryIds = const {}, }); String _val(List keys, [String fallback = '']) { @@ -67,7 +93,13 @@ class PickupCard extends StatelessWidget { @override Widget build(BuildContext context) { - final StopKind kind = stopKindOf(item); + // ── The label follows the leg, not the payload ── + // + // The booking adapter stamps `type: pickup` on every row it builds, so a + // milk-run order the rider has already collected still described itself as + // a pickup — and this card offered **Start Pickup** for a bag sitting in + // his box. See [MilkRun.workingKind]. + final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds); final String customer = _val([ 'pickupcustomer', @@ -85,22 +117,39 @@ class PickupCard extends StatelessWidget { final int collectQty = pickupParcelCount(item); final double cash = stopCollectionAmount(item); - // ── The card's one accent, driven by STATE not type ── + // ── The two ends of the journey, named ── // - // This was `kind.accent`, which meant the stop *type* coloured the left - // stripe, the badge and — worst — the primary CTA. So "Start Pickup" was - // maroon, "Start Delivery" royal blue and "Start Combined Stop" indigo: the - // single most important control on the screen changed colour according to a - // category, and a rider scanning for "the button" had no constant to look - // for. Uber's rule is one accent moment per screen precisely because the - // accent is the only thing that says *this* matters. + // Which name belongs at which end depends on the line of work, and getting + // it wrong is a rider riding to a subscriber's flat to collect a lunch: // - // So type is now carried in words (see the meta line) and the accent is - // spent on state, which is what actually varies and matters: amber when this - // is a resume of a skipped stop, the brand accent otherwise. - final Color accent = isSkipped - ? ColorConstants.warning - : ColorConstants.primary; + // • **Meal run.** He collects at a kitchen and hands over at a door, so + // the source names the pickup and the customer names the drop. + // • **Parcel run.** A booking is a first-mile collection *from* the + // customer, and where it goes afterwards is the hub's problem — so the + // customer names the pickup, and the drop end appears only if the + // payload actually carries one. + final String source = stopSourceName(item); + final bool pickupIsSource = + ServiceProfile.active.deliversToCustomer && source.isNotEmpty; + final String pickupName = pickupIsSource ? source : customer; + final String dropAddress = _val(['dropaddress', 'DropAddress']); + final String dropName = pickupIsSource + ? customer + : (dropAddress.isEmpty ? '' : 'Collection centre'); + + /// True once the rider is carrying it: the drop becomes the destination and + /// takes the top of the card. + final bool dropLeads = + pickupIsSource && (dropName.isNotEmpty || dropAddress.isNotEmpty); + + // ── No accent stripe any more ── + // + // The card carried a 4px coloured bar down its left edge, on the argument + // that state deserves an accent. It does — but the status tag at the top of + // the card says the same thing in a word, in a colour, at reading size, and + // it does not clip the corner radius or force an `IntrinsicHeight` around + // the whole card to have a height to stretch to. One statement of state per + // card, and it is the one with words on it. return Slidable( key: ValueKey('slidable_$orderId'), @@ -110,16 +159,14 @@ class PickupCard extends StatelessWidget { extentRatio: 0.35, children: [ SlidableAction( - borderRadius: BorderRadius.circular( - DesignConstants.radiusXl, - ), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), onPressed: (_) => _startStop(context, gated: true), // Same green as the Start button it is a shortcut for. backgroundColor: ColorConstants.acceptGreen, foregroundColor: Colors.white, icon: isSkipped - ? Icons.refresh_rounded - : Icons.navigation_rounded, + ? LucideIcons.rotateCw + : LucideIcons.navigation, label: isSkipped ? 'Resume' : 'Start', ), ], @@ -130,381 +177,325 @@ class PickupCard extends StatelessWidget { opacity: enabled ? 1 : 0.7, // ── The card itself opens the stop ── // - // Tapping a card did nothing here. Everything it knows — the map, the - // full address, the parcel breakdown, the customer's number, the notes — - // was reachable only through the ⋯ menu or by starting the stop, which - // is a commitment rather than a look. Home has opened a detail sheet on - // tap since it was built; a rider moving between the two tabs learned - // one gesture and found it dead on the other. - // - // `HitTestBehavior.opaque` so the whole card responds, including its - // padding. The buttons inside keep their own taps — a child's gesture - // wins over an ancestor's — so Start, Call and ⋯ are unaffected. - // PressScale, not a bare GestureDetector: this card opens a sheet that - // takes ~340ms to arrive, and with no press state the rider got no - // acknowledgement at all in that gap — the classic "did it register?" - // second tap. The dip under the thumb is the receipt. - child: PressScale( + // Everything this card knows — the map, the full address, the parcel + // breakdown, the customer's number, the notes — used to be reachable + // only through the ⋯ menu or by *starting* the stop, which is a + // commitment rather than a look. Tapping the card opens the same detail + // sheet Home opens, so a rider moving between the two tabs has one + // gesture rather than one that dies on arrival. + child: GlassCard( + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h), onTap: () => _openDetail(context), - semanticLabel: 'Stop $displayStep, $customer, $address', - semanticHint: 'Opens the full stop details', - child: Container( - margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h), - // ── Fill, not an edge ── - // - // Border and shadow both gone; the fill separates the card from the - // page on its own, matching the Home stop card and the way most large - // apps draw a list card. The border, the shadow and the fill were - // three devices doing one job. - // - // See [ColorConstants.cardSurface] for why the fill sits 9.4% off the - // page rather than the ~5% a "subtle" grey gives: with no edge, it is - // the only thing left doing the separating, and it has to hold up in - // sunlight. - decoration: BoxDecoration( - color: ColorConstants.cardSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), - // The card's one piece of chrome: a 4px stripe down the left edge. It - // now carries STATE — amber when this is a skipped stop being resumed, - // the brand accent otherwise — and costs no vertical space at all, - // which the badges and panels it replaced all did. Stop type moved to - // the chip on the meta line, so the stripe no longer doubles as a - // colour-coded category marker. - // - // IntrinsicHeight is load-bearing, not decoration. The stripe is a - // Container with a width and no height, so it only fills the card - // when the Row stretches it — and `CrossAxisAlignment.stretch` sizes - // children to the Row's own cross-axis extent. Inside a ListView - // that extent is UNBOUNDED, so the stripe was asked to be infinitely - // tall and the whole card failed to lay out: every stop rendered as - // nothing while the progress rail above (a separate widget reading - // the same list) kept showing the right count. IntrinsicHeight gives - // the Row a real height first, so stretch has something finite to - // stretch to. - child: ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Padding( + padding: EdgeInsets.fromLTRB(18.w, 16.h, 18.w, 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Name and state, on one line ── + // + // The destination's name is the headline of a delivery card: + // it is what he reads off the doorbell. The status tag rides + // beside it rather than on a line of its own, which is where a + // filled type chip and a "Stop 3" caption used to sit — three + // marks about categories above the one word that matters. + // ── Identity left, state and journey right ── + // + // The distance and ETA had a full-width row of their own under + // the bag — a fifth storey on a card the rider reads at a red + // light. Home's group node and this queue's own UP NEXT tile + // already carry the journey as a narrow right-hand column at + // the headline's shoulder; the live card was the one surface + // still spending a row on it. Same geometry now on all three: + // who and where on the left, the state and the ride stacked on + // the right, one fixation for the whole card and one row + // shorter. + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 4.w, - color: enabled ? accent : accent.withValues(alpha: 0.35), - ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - // ── One card, one box ── + Text( + (dropLeads ? dropName : pickupName).isEmpty + ? 'Not named' + : (dropLeads ? dropName : pickupName), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w800, + height: 1.1, + letterSpacing: -0.6, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + // ── The area, not the street ── // - // This stop card used to nest up to ten containers inside its - // own border: a filled type badge, a bordered "Stop #N" chip, an - // amber full-width ETA panel, a 30px tinted icon tile beside the - // address, a bordered "ACTION REQUIRED" panel holding two rows - // that each carried their own 24px tinted icon tile, and a - // bordered note panel — before reaching the button. Around 390px - // of height, so barely two stops fitted on a phone, and the - // rider scrolled to answer "where am I going next". + // This printed the raw address at up to four lines: + // `12, SNS Colony, Peelamedu, Coimbatore 641004`, + // wrapping under the customer's name on the one card + // that is supposed to be readable in a second. Three + // of those four components are the same on every stop + // of the run — the city, the state, the pincode — so + // they say nothing about *which* stop this is, and + // the door number is not something a rider navigates + // by from here. // - // Uber's Base system states the rule this broke: *the UI - // recedes, content leads* — containers stay subordinate to what - // they hold — and it prefers whitespace and contrast to layered - // elevation. A box does not make a thing important; being the - // biggest, darkest text on the card does. - // - // So every inner box is gone. What they contained is now type - // and colour on plain rows, and the accent survives in exactly - // two places: the card's left edge and the CTA. Uber calls that - // "a single accent moment"; four tinted panels competing was the - // opposite of one. - // - // Result is roughly half the height, so three to four stops fit - // where two did. - - // ── Meta line: what kind of stop, which one, how far ── - Padding( - padding: EdgeInsets.fromLTRB(14.w, 11.h, 12.w, 0), - child: Row( - children: [ - // Darkened filled chip — a mark, not a surface. The card - // stays neutral, so the type colour never becomes a pane. - // - // Flexible with an inner ellipsis, because the label is the - // only elastic thing on this line: "PICKUP & DELIVERY" is - // nearly three times the width of "PICKUP", and it shares - // the row with a stop number and the distance chip, both of - // which have a fixed claim. The label is what gives way — a - // truncated type word still reads, an overflowing row does - // not. - Flexible( - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, - vertical: 4.h, - ), - decoration: BoxDecoration( - color: enabled - ? kind.chipColor - : kind.chipColor.withValues( - alpha: 0.45, - ), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - kind.icon, - size: 12.sp, - color: Colors.white, - ), - SizedBox(width: 5.w), - Flexible( - child: Text( - kind.chipLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.5, - color: Colors.white, - fontFamily: - FontConstants.fontFamily, - ), - ), - ), - ], - ), - ), - ), - if (displayStep > 0) ...[ - SizedBox(width: 7.w), - Text( - 'Stop $displayStep', - maxLines: 1, - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.2, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - SizedBox(width: 8.w), - const Spacer(), - // Distance and ETA lost their amber panel but not their - // prominence — this is still the pair he checks at every red - // light, so it is the one bordered thing on the line. - _DistanceEta( - meters: distanceMeters, - speedMps: riderSpeedMps, - muted: !enabled, - ), - ], + // The locality is what he is deciding on. The full + // string is one tap away on the detail sheet and it + // is what the map at the door uses, so nothing is + // lost — see [areaOf]. + Text( + () { + final full = dropLeads + ? (dropAddress.isEmpty + ? address + : dropAddress) + : address; + final area = areaOf(item, preferDrop: dropLeads); + return area.isEmpty ? full : area; + }(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + height: 1.35, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, ), ), - // ── Identity: the name is the thing he shouts at the gate ── - Padding( - padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - customer, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 19.sp, - fontWeight: FontWeight.w800, - height: 1.15, - letterSpacing: -0.3, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 3.h), - // Two lines, not three. A third line of street - // address never changes which gate he walks to, and - // it cost every card in the list 18px. - // - // See [kFullAddressTrial] for the design switch that - // lifts this on both stop-card screens at once. - Text( - address, - maxLines: kFullAddressTrial - ? kFullAddressMaxLines - : 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.5.sp, - height: 1.35, - fontWeight: FontWeight.w600, - // Slate, not the brown M3 leftover, and dark - // enough to stay legible on the card's fill. - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ), - if (phone.isNotEmpty) ...[ - SizedBox(width: 8.w), - _CallButton(phone: phone), - ], - ], + // ── The pairing, as one object ── + // + // `Bag 1 · Joe`, not a bag chip in one place and a + // name in another. The join between them is the + // entire operational risk on a meal run, and making + // the rider do it himself at a gate is where the + // wrong lunch reaches the wrong door. (The name is + // the headline above, so the pair is spatial here; + // the written form earns its place further down the + // queue — see [_LaterRow] — and on the manifest.) + if (bag.isNotEmpty) ...[ + SizedBox(height: 14.h), + _BagPair( + bag: bag, + customer: '', + muted: !enabled, + size: 15.5, ), - ), - - // ── What to do here, as one line of text ── - _ActionLine( - kind: kind, - deliverQty: deliverQty, - collectQty: collectQty, - cash: cash, - skipped: isSkipped, - ), - - // ── Customer note: a line with an icon, not a panel ── - if (notes.isNotEmpty) - Padding( - padding: EdgeInsets.fromLTRB(14.w, 6.h, 12.w, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.sticky_note_2_rounded, - size: 13.sp, - color: ColorConstants.warning, - ), - SizedBox(width: 6.w), - Expanded( - child: Text( - notes, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - height: 1.3, - fontWeight: FontWeight.w700, - color: ColorConstants.warning, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], - ), - ), - - // ── 5. Primary CTA — the only thing to press ── - Padding( - padding: EdgeInsets.fromLTRB( - 14.w, - 12.h, - 14.w, - 12.h, - ), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: _tapTarget(48.h), - // ── Green, filled, and the loudest thing - // on the card ── - // - // This was the stop-kind accent — brand - // maroon on a pickup — which put the - // forward action in the same hue the app - // uses for Reject and for destructive - // confirmations. One rule now holds across - // the whole run: green advances the job, - // maroon is brand and navigation, red - // destroys. See [MilerButton]. - // - // Hand-rolled `ElevatedButton` replaced by - // the shared button so the radius, the - // label size and the press state match - // every other CTA the rider presses today. - child: enabled - ? MilerButton( - label: isSkipped - ? 'Resume stop' - : kind.startCtaLabel, - icon: isSkipped - ? Icons.refresh_rounded - : Icons.arrow_forward_rounded, - color: ColorConstants.acceptGreen, - height: _tapTarget(48.h), - onPressed: () => - _startStop(context), - ) - : Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: - BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: - ColorConstants.borderStrong, - ), - ), - child: Text( - 'Finish the current stop first', - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 13.5.sp, - color: ColorConstants - .secondaryText, - fontFamily: - FontConstants.fontFamily, - ), - ), - ), - ), - ), - if (!isSkipped) ...[ - SizedBox(width: 10.w), - _SquareIconButton( - icon: Icons.more_horiz_rounded, - size: _tapTarget(48.h), - background: ColorConstants.pureSurface, - border: ColorConstants.borderStrong, - iconColor: ColorConstants.slateText, - semanticLabel: 'More options', - onTap: () { - final parentState = context - .findAncestorStateOfType< - _MyPickupsState - >(); - if (parentState != null) { - _showMyOptionsSheet( - context, - item, - parentState, - ); - } - }, - ), - ], - ], - ), - ), + ], ], ), ), + SizedBox(width: 12.w), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + // The task, not the record. `OrderStatusTag` says + // "Picked up", a true statement about the past; the + // word this card needs is the one naming the next + // physical act. Read off the ladder, not off + // `orderstatus` — see [MilkRun.stageOf]. + _TaskChip( + status: MilkRun.stageOf( + item, + acceptedIds: const {}, + collectedIds: collectedIds, + outForDeliveryIds: outForDeliveryIds, + ), + ), + SizedBox(height: 10.h), + // How far, and how long — the pair he checks at every + // red light, at the same shoulder Home puts it. + _JourneyBlock( + meters: distanceMeters, + speedMps: riderSpeedMps, + muted: !enabled, + ), + ], + ), ], ), - ), + + // Money is not a detail — it is the one fact on this card the + // rider is personally accountable for. + if (cash > 0 || deliverQty > 1 || collectQty > 1) ...[ + SizedBox(height: 10.h), + _ActionLine( + kind: kind, + deliverQty: deliverQty, + collectQty: collectQty, + cash: cash, + skipped: isSkipped, + ), + ], + + // A note is what the customer said that the address does not: + // "gate code 4412", "call before you come up". + if (notes.isNotEmpty) ...[ + SizedBox(height: 10.h), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.stickyNote, + size: 16.sp, + color: ColorConstants.warning, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + notes, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5.sp, + height: 1.35, + fontWeight: FontWeight.w600, + color: ColorConstants.warning, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ], + + // ── Where it came from, quietly ── + // + // Once the food is in the box the pickup is history — but it is + // still what establishes custody, and on a run with two kitchens + // it is how he knows which shelf this bag came off. So it stays, + // one line, under a hairline, at half the weight of the name + // above. + if (dropLeads && pickupName.isNotEmpty) ...[ + SizedBox(height: 14.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 12.h), + Row( + children: [ + Icon( + LucideIcons.store, + size: 16.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 8.w), + Text( + 'PICKED UP FROM', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + pickupName, + // Two lines before an ellipsis — the same rule the + // route header earned on a device: "DailyGrubs RS + // Puram K…" cuts exactly the words that say which + // kitchen, and a wrong-source assumption at a + // handover is a real dispute later. + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + height: 1.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ], + + // ── The only thing to press ── + SizedBox(height: 16.h), + Row( + children: [ + Expanded( + child: SizedBox( + height: _tapTarget(ButtonSizes.primary), + // ── Brand red, because this button *navigates* ── + // + // The rule across the run is: green advances the job at + // a door, brand red is navigation and identity, red + // destroys. Start opens the map screen and sets the + // rider moving — it finishes nothing — so it belongs to + // the navigation colour, and it is the loudest thing on + // the card either way. + child: enabled + ? MilerButton( + label: isSkipped + ? 'Resume stop' + : kind.startCtaLabel, + icon: isSkipped + ? LucideIcons.rotateCw + : LucideIcons.navigation, + color: ColorConstants.primary, + height: _tapTarget(ButtonSizes.primary), + onPressed: () => _startStop(context), + ) + : Container( + alignment: Alignment.center, + decoration: BoxDecoration( + // Canvas, not neutralLight: on this white + // card the old fill measured 1.02:1 — a + // disabled bar you had to be told about. + color: MilerSurface.canvas, + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), + ), + child: Text( + 'Finish the current stop first', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14.sp, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + if (phone.isNotEmpty) ...[ + SizedBox(width: 10.w), + _CallButton(phone: phone), + ], + if (!isSkipped) ...[ + SizedBox(width: 10.w), + _SquareIconButton( + icon: LucideIcons.ellipsis, + size: _tapTarget(ButtonSizes.primary), + background: MilerSurface.canvas, + border: Colors.transparent, + iconColor: ColorConstants.slateText, + semanticLabel: 'More options', + onTap: () { + final parentState = context + .findAncestorStateOfType<_MyPickupsState>(); + if (parentState != null) { + _showMyOptionsSheet(context, item, parentState); + } + }, + ), + ], + ], + ), + ], ), ), ), @@ -536,6 +527,33 @@ class PickupCard extends StatelessWidget { final parentState = context.findAncestorStateOfType<_MyPickupsState>(); if (parentState == null) return; + // ── Never a collection, on a line that collects in bulk ── + // + // This tab is gated on the collected set, so on a kitchen line every card + // here is already a delivery and this never fires. It is asked anyway + // because this method opens the map screen directly rather than through + // `startPickup`, and a stop that reached it in the pickup phase would put + // the rider through a map, an I'VE ARRIVED and a confirmation sheet for a + // bag he is supposed to collect at the counter with the rest of them. See + // [MilkRun.worksOnOwnScreen]. + if (!MilkRun.worksOnOwnScreen(item, collectedIds: collectedIds)) { + AppFeedback.info( + context, + 'Collections are confirmed on Home — select the orders at the ' + 'kitchen and slide to update.', + ); + return; + } + + // ── No release gate here any more ── + // + // A collected order is a live delivery the moment it is in the rider's + // hands. Holding it until every kitchen was done meant a rider who had + // collected three lunches at 11:40 could not start dropping them while a + // second kitchen was still cooking — so he waited, and the first three + // customers ate late for no reason. Kitchens are worked one at a time and + // each one's orders go straight onto the round. + if (gated) { if (!await parentState.ensureOnlineForAction()) return; if (!context.mounted) return; @@ -566,6 +584,74 @@ class PickupCard extends StatelessWidget { // one journey rather than two numbers that happen to be adjacent, and the // chip's edge does the separating that four different font weights were trying // to do. One chip, not two: they are never useful apart. +/// The journey as a right-hand column: distance leading, ride time under it, +/// both right-aligned. The same two facts `_DistanceEta` prints in a line — +/// that form survives for the quieter tiers, where the row is shared with a +/// name — but the live card stacks them at the headline's shoulder, exactly +/// where Home's group node puts them, so the two screens' biggest cards agree +/// about where "how far" lives. Nothing renders when no distance is known: +/// a dash column would be furniture. +class _JourneyBlock extends StatelessWidget { + final double? meters; + final double? speedMps; + final bool muted; + + const _JourneyBlock({ + required this.meters, + required this.speedMps, + this.muted = false, + }); + + @override + Widget build(BuildContext context) { + final distance = RouteMetricsHelper.formatDistance(meters); + if (distance == '\u2014') return const SizedBox.shrink(); + final travel = RouteMetricsHelper.formatDuration( + RouteMetricsHelper.travelTime(meters, liveSpeedMps: speedMps), + ); + + final Color fg = muted + ? ColorConstants.secondaryText + : ColorConstants.slateText; + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.navigation, size: 15.sp, color: fg), + SizedBox(width: 4.w), + Text( + distance, + style: TextStyle( + fontSize: 15.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: fg, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + if (travel != '\u2014') ...[ + SizedBox(height: 2.h), + Text( + '~$travel', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ); + } +} + class _DistanceEta extends StatelessWidget { final double? meters; final double? speedMps; @@ -589,38 +675,25 @@ class _DistanceEta extends StatelessWidget { ? ColorConstants.secondaryText : ColorConstants.slateText; - return Container( - padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 4.h), - // White, not the app's neutral grey: the card underneath is now grey, and - // a grey chip on a grey card has no edge at all. Inner surfaces go lighter - // than their container. - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - border: Border.all( - color: muted - ? ColorConstants.borderSubtle - : ColorConstants.borderStrong, - width: 1.2, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.near_me_rounded, size: 13.sp, color: fg), - SizedBox(width: 5.w), - Text( - travel == '—' ? distance : '$distance · $travel', - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - color: fg, - fontFamily: FontConstants.fontFamily, - ), + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.navigation, size: 16.sp, color: fg), + SizedBox(width: 5.w), + Text( + // `~`, matching the live card's journey block: every ETA on this + // queue is an estimate, and two tiers spelling that differently + // reads as two data sources. + travel == '—' ? distance : '$distance · ~$travel', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: fg, + fontFamily: FontConstants.fontFamily, ), - ], - ), + ), + ], ); } } @@ -664,7 +737,7 @@ class _ActionLine extends StatelessWidget { TextStyle style(Color c) => TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: c, fontFamily: FontConstants.fontFamily, ); @@ -727,20 +800,43 @@ class _ActionLine extends StatelessWidget { class _CallButton extends StatelessWidget { final String phone; - const _CallButton({required this.phone}); + + /// Edge length, when it has to line up with something. + /// + /// Defaults to the card's own 52. The map sheet passes + /// [ButtonSizes.primary] (56) so the button sits flush in a row beside + /// Navigate and "I've arrived" — four points short reads as misaligned when + /// three controls share one line. + final double? size; + + /// Who the rider is ringing, as a spoken action — `Call the customer`. + /// + /// Defaults to the neutral form. A caller that knows the leg passes the + /// resolved one, so the label can never say *customer* while the number is + /// the kitchen's. See [StopContact]. + final String? semanticLabel; + + const _CallButton({required this.phone, this.size, this.semanticLabel}); @override Widget build(BuildContext context) { return _SquareIconButton( - icon: Icons.call_rounded, - size: math.max(52.0, 52.w), - // Neutral, like its twin on the Home stop row: a green-tinted tile put a - // second green control beside a green-adjacent CTA, and green already - // means "done" elsewhere in the app. Calling is an affordance, not a state. - background: ColorConstants.pureSurface, - border: ColorConstants.borderStrong, - iconColor: ColorConstants.slateText, - semanticLabel: 'Call customer', + icon: LucideIcons.phone, + size: size ?? math.max(52.0, 52.w), + // ── Green, and the same green everywhere a phone appears ── + // + // It was a neutral grey tile with a maroon handset, on the reasoning that + // green means "done". That reasoning cost more than it saved: the call + // control on the Home route card is green-tinted, the one on the map + // sheet was grey, and a rider reaching for the same affordance on two + // screens met two different objects. Green is the phone colour in every + // app he already uses — it is what the glyph means before the palette + // gets a say — and "done" is carried by a tick and a word, not by a hue + // on a handset. + background: ColorConstants.acceptGreen.withValues(alpha: 0.12), + border: Colors.transparent, + iconColor: ColorConstants.acceptGreen, + semanticLabel: semanticLabel ?? 'Call this stop', onTap: () async { // A dialer that fails to open is silent by default: the rider is at // the door, taps Call, and nothing happens. Say so, and show him the @@ -800,3 +896,642 @@ class _SquareIconButton extends StatelessWidget { ); } } + +/// ───────────────────────────────────────────────────────────────────────── +/// THE QUEUE, IN THREE DENSITIES +/// +/// ``` +/// NOW one task card, full weight, one big action +/// UP NEXT who and where, quietly — no action +/// LATER one line each: bag, name, distance +/// SKIPPED the reason, and the way back in +/// ``` +/// +/// ── Why the list is not one card repeated ── +/// +/// It was: every stop drew the same card, and "which one is live" was expressed +/// by dimming the others to 70% and swapping their button for a sentence. That +/// is a *logic* rule wearing a very quiet costume — a rider scanning a screen of +/// eight identical cards still has to find the bright one, which is exactly the +/// decision this screen exists to take off him. +/// +/// Density is the honest encoding. The live stop is physically bigger and +/// carries the only button on the screen; the next one is legible but plainly +/// secondary; the rest are one line each. Nobody has to compare opacities. +/// +/// It also costs less: eight full cards is eight screens of scrolling for a day +/// a rider can now see most of at once. +enum QueueDensity { + /// The one stop being worked. Exactly one of these exists at a time. + now, + + /// The stop after it — enough to prepare for, nothing to act on. + next, + + /// Everything after that. + later, + + /// Been to, not finished. Carries its reason and a way back in. + skipped, +} + +/// One row of the delivery queue: a heading, or a stop with everything the list +/// already decided about it. See `_MyPickupsState._queueRows`. +class _QueueRow { + final String? heading; + + /// How many stops the heading covers, when it is worth saying — `LATER 3`. + final int count; + + final Map? stop; + final QueueDensity density; + final int displayStep; + + /// The bag this order travels in, as recorded at the counter. + final String bag; + + /// Why a skipped stop was skipped. Read from the skip cache rather than the + /// stop, because the stop is refetched from the API every three seconds and + /// the reason is the rider's, not the server's. + final String reason; + + const _QueueRow.heading(this.heading, {this.count = 0}) + : stop = null, + density = QueueDensity.later, + displayStep = 0, + bag = '', + reason = ''; + + const _QueueRow.stop({ + required this.stop, + required this.density, + required this.displayStep, + required this.bag, + this.reason = '', + }) : heading = null, + count = 0; + + bool get isHeading => heading != null; + + /// Only the live stop is actionable. This is the rule the whole screen turns + /// on, kept in one place so a card cannot decide otherwise. + bool get enabled => + density == QueueDensity.now || density == QueueDensity.skipped; +} + +/// A section heading in the delivery queue. +/// +/// Type, not a container: three words on a page, at the weight that separates +/// sections without becoming a bar. A tinted strip here would be furniture +/// between the rider and the card he is meant to be reading. +class _QueueHeading extends StatelessWidget { + final String label; + final int count; + + /// What ordered this queue, shown beside the first heading. + /// + /// ── Why a screen says this at all ── + /// + /// A list ordered by the hub's solved route and a list the app ordered by + /// what is nearest look exactly the same, and only one of them is a plan the + /// office, the customer and the rider all share. When the app is guessing it + /// has to say so — otherwise "follow your route" means one thing upstairs and + /// another on the bike. See [RouteOrder]. + final RouteOrderSource? order; + + const _QueueHeading({required this.label, this.count = 0, this.order}); + + @override + Widget build(BuildContext context) { + // NOW is the live section and the only one that carries the accent — the + // rest are signposts, and a page of red labels would spend the accent on + // wayfinding rather than on the work. + final live = label == 'NOW'; + + // ── On the cards' own text line ── + // + // 20 put this on nothing: the cards' white edge is at 16 and every name + // inside them starts at 16 + 18 = 34, so the eyebrow hovered four points + // off one line and fourteen off the other — the near-miss distance that + // reads as a mistake. It sits on the text line, so the page has one + // reading edge: NOW, the customer, UP NEXT, the next customer, straight + // down. The right inset mirrors it for the provenance chip. + return Padding( + padding: EdgeInsets.fromLTRB(34.w, live ? 12.h : 22.h, 34.w, 8.h), + child: Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + color: live + ? ColorConstants.primary + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + if (count > 0) ...[ + SizedBox(width: 8.w), + Text( + '$count', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + if (order != null) ...[ + const Spacer(), + Flexible(child: _OrderProvenance(source: order!)), + ], + ], + ), + ); + } +} + +/// `Hub route` / `Nearest first` — where the queue's order came from. +/// +/// Deliberately quiet. It is a provenance mark, not a warning: nearest-first +/// is a reasonable order when nobody has assigned one, and the rider does not +/// need alarming about it. What he needs is to not be told it is his route. +class _OrderProvenance extends StatelessWidget { + final RouteOrderSource source; + + const _OrderProvenance({required this.source}); + + @override + Widget build(BuildContext context) { + final assigned = source.isAdmin; + final tint = assigned + ? ColorConstants.acceptGreen + : ColorConstants.secondaryText; + + return Tooltip( + message: source.explanation, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), + decoration: BoxDecoration( + color: tint.withValues(alpha: assigned ? 0.10 : 0.07), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + assigned ? LucideIcons.route : LucideIcons.navigation, + size: 11.sp, + color: tint, + ), + SizedBox(width: 4.w), + Flexible( + child: Text( + source.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + color: tint, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// `Bag 1 · Joe` — the pairing, as one object. +/// +/// ── Why they are one line and not two facts ── +/// +/// The rider is holding a bag and looking for a door. Printing the bag in one +/// place and the customer in another makes him do the join himself, on a phone, +/// at a gate — and that join is the entire operational risk on a meal run: five +/// near-identical lunch bags, one confident rider, the wrong door. +/// +/// So they are written the way he thinks them. +class _BagPair extends StatelessWidget { + final String bag; + final String customer; + final bool muted; + + /// Bigger on the live card, quieter down the queue. + final double size; + + const _BagPair({ + required this.bag, + required this.customer, + this.muted = false, + this.size = 15, + }); + + String get _bagText => + bag.toLowerCase().startsWith('bag ') ? bag : 'Bag $bag'; + + @override + Widget build(BuildContext context) { + if (bag.isEmpty) return const SizedBox.shrink(); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), + decoration: BoxDecoration( + color: muted + ? MilerSurface.canvas + : ColorConstants.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Text( + _bagText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: size.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: muted + ? ColorConstants.secondaryText + : ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (customer.isNotEmpty) ...[ + SizedBox(width: 8.w), + Flexible( + child: Text( + '· $customer', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: size.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ], + ); + } +} + +/// What the rider is being asked to do here, in one word. +/// +/// Deliberately *task* wording rather than record wording: the status tag says +/// "Picked up", which is a fact about the past. On the card he is working, the +/// useful word is the one that describes the next physical act — READY, +/// ARRIVED, DONE — because that is what he is checking the card to find out. +class _TaskChip extends StatelessWidget { + final StopStatus status; + const _TaskChip({required this.status}); + + ({String label, Color color}) get _look => switch (status) { + StopStatus.deliveryArrived => ( + label: 'ARRIVED', + color: ColorConstants.primary, + ), + StopStatus.arrived => (label: 'AT PICKUP', color: ColorConstants.primary), + // ── The two rungs the ladder used to collapse into one ── + // + // Collected but not yet riding, and riding. Both fell through to READY, + // which is why the jump from Picked to the delivery-active state was + // invisible on this card: the word never changed, so the only thing the + // rider had to go on was the ladder's own behaviour. They are the same + // green — neither is a warning — and differ in the word, which is the part + // that is read. + StopStatus.picked => (label: 'PICKED', color: ColorConstants.acceptGreen), + StopStatus.outForDelivery => ( + label: 'ACTIVE', + color: ColorConstants.primary, + ), + StopStatus.delivered => (label: 'DONE', color: ColorConstants.acceptGreen), + StopStatus.skipped => (label: 'SKIPPED', color: ColorConstants.warning), + StopStatus.cancelled => ( + label: 'CANCELLED', + color: ColorConstants.secondaryText, + ), + _ => (label: 'READY', color: ColorConstants.acceptGreen), + }; + + @override + Widget build(BuildContext context) { + final look = _look; + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + decoration: BoxDecoration( + color: look.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: Text( + look.label, + maxLines: 1, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + color: look.color, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + } +} + +/// UP NEXT — where he is going after this one. +/// +/// Enough to prepare for and nothing to act on: no button, no source line, no +/// notes, no money. Preparing is a glance; acting is the card above. +class _NextCard extends StatelessWidget { + final Map item; + final String bag; + final double? distanceMeters; + final double? riderSpeedMps; + final VoidCallback onOpen; + + const _NextCard({ + required this.item, + required this.bag, + required this.distanceMeters, + required this.riderSpeedMps, + required this.onOpen, + }); + + @override + Widget build(BuildContext context) { + final name = _destinationName(item); + final address = _destinationAddress(item); + + return GlassCard( + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 5.h), + onTap: onOpen, + child: Padding( + padding: EdgeInsets.fromLTRB(18.w, 14.h, 18.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + // A step down from NOW's hero, a step up from LATER's + // row. The three tiers now differ in weight as well as + // in position, which is what makes their labels a + // convenience rather than the only thing ranking them. + fontSize: 17.sp, + fontWeight: FontWeight.w600, + height: 1.15, + letterSpacing: -0.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 10.w), + _DistanceEta( + meters: distanceMeters, + speedMps: riderSpeedMps, + muted: true, + ), + ], + ), + if (address.isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + if (bag.isNotEmpty) ...[ + SizedBox(height: 10.h), + _BagPair(bag: bag, customer: '', muted: true, size: 14), + ], + ], + ), + ), + ); + } +} + +/// LATER — one line each. +/// +/// Bag, name, distance. That is the whole row, because until a stop is next +/// there is nothing a rider can usefully do with anything else about it, and +/// eight full cards is eight screens of scrolling for a day he should be able to +/// take in at once. +class _LaterRow extends StatelessWidget { + final Map item; + final String bag; + final double? distanceMeters; + final VoidCallback onOpen; + + const _LaterRow({ + required this.item, + required this.bag, + required this.distanceMeters, + required this.onOpen, + }); + + @override + Widget build(BuildContext context) { + final name = _destinationName(item); + + return InkWell( + onTap: onOpen, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), + child: Row( + children: [ + if (bag.isNotEmpty) ...[ + _BagPair(bag: bag, customer: '', muted: true, size: 13), + SizedBox(width: 12.w), + ], + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 10.w), + Text( + RouteMetricsHelper.formatDistance(distanceMeters), + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } +} + +/// SKIPPED — what happened, and the way back in. +/// +/// Never the word "Failed". Operations needs the reason and the rider needs a +/// route back to a stop he has already ridden to once, so the row carries both +/// and nothing else. +class _SkippedRow extends StatelessWidget { + final Map item; + final String bag; + final String reason; + final VoidCallback onResume; + final VoidCallback onOpen; + + const _SkippedRow({ + required this.item, + required this.bag, + required this.reason, + required this.onResume, + required this.onOpen, + }); + + @override + Widget build(BuildContext context) { + final name = _destinationName(item); + final why = reason.trim().isNotEmpty + ? reason.trim() + : (item['skipreason'] ?? item['notes'] ?? '').toString().trim(); + + return GlassCard( + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 5.h), + onTap: onOpen, + child: Padding( + padding: EdgeInsets.fromLTRB(18.w, 14.h, 18.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 10.w), + const _TaskChip(status: StopStatus.skipped), + ], + ), + if (why.isNotEmpty) ...[ + SizedBox(height: 6.h), + Text( + why, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w600, + height: 1.3, + color: ColorConstants.warning, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + SizedBox(height: 12.h), + Row( + children: [ + if (bag.isNotEmpty) ...[ + _BagPair(bag: bag, customer: '', muted: true, size: 13.5), + SizedBox(width: 12.w), + ], + Expanded( + child: SizedBox( + height: ButtonSizes.secondary, + child: MilerButton( + label: 'Resume', + icon: LucideIcons.rotateCw, + variant: MilerButtonVariant.outlined, + color: ColorConstants.primary, + height: ButtonSizes.secondary, + onPressed: onResume, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Where this stop is going, whichever line of work it belongs to. +/// +/// Shared by the three quiet densities so a name cannot be resolved one way on +/// the live card and another way two rows down. +String _destinationName(Map item) { + final customer = (item['pickupcustomer'] ?? item['tenantname'] ?? '') + .toString() + .trim(); + if (customer.isNotEmpty) return customer; + final source = stopSourceName(item); + return source.isEmpty ? 'Stop' : source; +} + +/// Where a stop is, for the collapsed queue — the **area**, not the street. +/// +/// A rider reading twenty rows is choosing between localities; twenty street +/// strings, each cut off mid-word, answer nothing and cost a line each to do +/// it. The full address is on the detail sheet and in the map at the door. +/// See [areaOf]. +String _destinationAddress(Map item) { + final area = areaOf( + item, + preferDrop: ServiceProfile.active.deliversToCustomer, + ); + if (area.isNotEmpty) return area; + + // Nothing usable to reduce — fall back to whatever the row carries rather + // than showing a blank where a place should be. + final drop = (item['dropaddress'] ?? item['DropAddress'] ?? '') + .toString() + .trim(); + if (ServiceProfile.active.deliversToCustomer && drop.isNotEmpty) return drop; + return (item['pickupaddress'] ?? '').toString().trim(); +} diff --git a/lib/views/Dashboard/pickups/collect_payment.dart b/lib/views/Dashboard/pickups/collect_payment.dart index 7dff3a6..cb54113 100644 --- a/lib/views/Dashboard/pickups/collect_payment.dart +++ b/lib/views/Dashboard/pickups/collect_payment.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -10,7 +11,7 @@ import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; -import 'package:miler/views/helpers/widgets/page_transitions.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; /// ───────────────────────────────────────────────────────────────────────── /// COLLECT PAYMENT — money taken at the door on the shipper's behalf. @@ -119,7 +120,7 @@ extension _MethodUi on _Method { : 'Customer scans and pays'; IconData get icon => - this == _Method.cash ? Icons.payments_rounded : Icons.qr_code_2_rounded; + this == _Method.cash ? LucideIcons.banknote : LucideIcons.qrCode; Color get accent => this == _Method.cash ? ColorConstants.acceptGreen @@ -289,11 +290,12 @@ class _CollectPaymentPageState extends State { } void _declinePayment() async { - final ok = await showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - backgroundColor: Colors.transparent, - isScrollControlled: true, + // Through the kit. The route flags here were already the right ones by + // hand — transparent, scroll-controlled, the app's entrance — which is + // exactly the problem a presenter solves: four correct copies are four + // places for the fifth to be written wrong. + final ok = await showMilerSheet( + context, builder: (_) => _ConfirmSheet( title: 'Customer is not paying?', body: @@ -310,7 +312,6 @@ class _CollectPaymentPageState extends State { /// See [AppFeedback]. void _toast(String message) => AppFeedback.error(context, message); - static String _fmt(double v) => v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(2); @@ -326,7 +327,10 @@ class _CollectPaymentPageState extends State { backgroundColor: ColorConstants.pureSurface, // Same bar as the verification screens this follows: taller, clear of // the notch, title at a size that survives sunlight and arm's length. - appBar: milerPageBar('Collect payment', onBack: () => Navigator.pop(context)), + appBar: milerPageBar( + 'Collect payment', + onBack: () => Navigator.pop(context), + ), body: Column( children: [ Expanded( @@ -376,7 +380,7 @@ class _CollectPaymentPageState extends State { 'DUE AT THIS STOP', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.8, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -390,7 +394,7 @@ class _CollectPaymentPageState extends State { '₹${_fmt(_due)}', style: TextStyle( fontSize: 42.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -1.6, height: 1.05, color: ColorConstants.slateText, @@ -405,7 +409,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -456,7 +460,7 @@ class _CollectPaymentPageState extends State { label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: strong ? FontWeight.w800 : FontWeight.w600, + fontWeight: strong ? FontWeight.w700 : FontWeight.w500, color: strong ? ColorConstants.slateText : ColorConstants.secondaryText, @@ -468,7 +472,7 @@ class _CollectPaymentPageState extends State { value, style: TextStyle( fontSize: strong ? 14.sp : 12.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: strong ? ColorConstants.slateText : ColorConstants.secondaryText, @@ -496,17 +500,16 @@ class _CollectPaymentPageState extends State { Widget _orderCard() { final facts = <(IconData, String, String)>[ if (_orderId.isNotEmpty) - (Icons.receipt_long_rounded, 'Consignment', _orderId), - if (_shipper.isNotEmpty) - (Icons.storefront_rounded, 'On behalf of', _shipper), + (LucideIcons.receiptText, 'Consignment', _orderId), + if (_shipper.isNotEmpty) (LucideIcons.store, 'On behalf of', _shipper), if (_parcels > 0) ( _kind.icon, _parcels == 1 ? 'Parcel' : 'Parcels', '$_parcels collected', ), - if (_weight.isNotEmpty) (Icons.scale_rounded, 'Weight', '$_weight kg'), - if (_itemNote.isNotEmpty) (Icons.notes_rounded, 'Noted', _itemNote), + if (_weight.isNotEmpty) (LucideIcons.weight, 'Weight', '$_weight kg'), + if (_itemNote.isNotEmpty) (LucideIcons.stickyNote, 'Noted', _itemNote), ]; return _card( @@ -529,7 +532,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.35, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -555,7 +558,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -570,7 +573,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -598,7 +601,7 @@ class _CollectPaymentPageState extends State { _field( controller: _reference, hint: 'UPI reference (optional)', - icon: Icons.tag_rounded, + icon: LucideIcons.tag, keyboard: TextInputType.number, formatters: [FilteringTextInputFormatter.digitsOnly], ), @@ -628,7 +631,7 @@ class _CollectPaymentPageState extends State { 'Show this to the customer', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.upiPurple, fontFamily: FontConstants.fontFamily, ), @@ -666,7 +669,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -677,7 +680,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -719,7 +722,7 @@ class _CollectPaymentPageState extends State { m.label, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -728,7 +731,7 @@ class _CollectPaymentPageState extends State { m.blurb, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -737,9 +740,7 @@ class _CollectPaymentPageState extends State { ), ), Icon( - selected - ? Icons.radio_button_checked_rounded - : Icons.radio_button_unchecked_rounded, + selected ? LucideIcons.circleDot : LucideIcons.circle, size: 20.sp, color: selected ? m.accent : ColorConstants.borderStrong, ), @@ -787,7 +788,7 @@ class _CollectPaymentPageState extends State { _field( controller: _received, hint: 'Amount you actually took', - icon: Icons.currency_rupee_rounded, + icon: LucideIcons.indianRupee, keyboard: const TextInputType.numberWithOptions(decimal: true), formatters: [ FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), @@ -797,7 +798,7 @@ class _CollectPaymentPageState extends State { _field( controller: _shortReason, hint: 'Why? e.g. customer had no change', - icon: Icons.notes_rounded, + icon: LucideIcons.stickyNote, maxLines: 2, capitalization: TextCapitalization.sentences, ), @@ -831,11 +832,7 @@ class _CollectPaymentPageState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.account_balance_wallet_rounded, - size: 17.sp, - color: ColorConstants.tertiary, - ), + Icon(LucideIcons.wallet, size: 17.sp, color: ColorConstants.tertiary), SizedBox(width: 10.w), Expanded( child: Text( @@ -844,7 +841,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.4, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.tertiary, fontFamily: FontConstants.fontFamily, ), @@ -878,7 +875,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -893,7 +890,7 @@ class _CollectPaymentPageState extends State { 'Customer cannot pay', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.errorRed, fontFamily: FontConstants.fontFamily, ), @@ -938,7 +935,7 @@ class _CollectPaymentPageState extends State { 'Finish the details above', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -962,7 +959,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -973,7 +970,7 @@ class _CollectPaymentPageState extends State { color: ColorConstants.pureSurface, shape: BoxShape.circle, ), - child: Icon(Icons.check_rounded, size: 22.sp, color: _method.accent), + child: Icon(LucideIcons.check, size: 22.sp, color: _method.accent), ), ), ); @@ -991,7 +988,7 @@ class _CollectPaymentPageState extends State { 'Collect payment', style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1004,7 +1001,7 @@ class _CollectPaymentPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.check_circle_rounded, + LucideIcons.circleCheck, size: 54.sp, color: ColorConstants.acceptGreen, ), @@ -1013,7 +1010,7 @@ class _CollectPaymentPageState extends State { 'Nothing to collect here', style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1025,7 +1022,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 13.sp, height: 1.4, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1033,7 +1030,7 @@ class _CollectPaymentPageState extends State { SizedBox(height: 20.h), MilerButton( label: 'Continue', - icon: Icons.arrow_forward_rounded, + icon: LucideIcons.arrowRight, color: ColorConstants.acceptGreen, onPressed: () => Navigator.pop(context, { 'paid': true, @@ -1066,7 +1063,7 @@ class _CollectPaymentPageState extends State { title, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1101,7 +1098,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: selected ? Colors.white : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1128,7 +1125,7 @@ class _CollectPaymentPageState extends State { onChanged: (_) => setState(() {}), style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1174,7 +1171,7 @@ class _CollectPaymentPageState extends State { ), child: Row( children: [ - Icon(Icons.info_rounded, size: 16.sp, color: accent), + Icon(LucideIcons.info, size: 16.sp, color: accent), SizedBox(width: 8.w), Expanded( child: Text( @@ -1182,7 +1179,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.35, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: accent, fontFamily: FontConstants.fontFamily, ), @@ -1208,65 +1205,38 @@ class _ConfirmSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - top: false, - child: Container( - margin: EdgeInsets.all(14.r), - padding: EdgeInsets.all(18.r), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 7.h), - Text( - body, - style: TextStyle( - fontSize: 13.sp, - height: 1.45, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 18.h), - Row( - children: [ - Expanded( - child: MilerButton( - label: 'Go back', - variant: MilerButtonVariant.outlined, - color: ColorConstants.slateText, - height: ButtonSizes.secondary, - onPressed: () => Navigator.pop(context, false), - ), + // The kit's fabric — this was a floating white card with margins on all + // sides, the one sheet in the app not anchored to the bottom edge. + return MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MilerSheetHeader(title: title, subtitle: body), + SizedBox(height: 18.h), + Row( + children: [ + Expanded( + child: MilerButton( + label: 'Go back', + variant: MilerButtonVariant.outlined, + color: ColorConstants.slateText, + height: ButtonSizes.secondary, + onPressed: () => Navigator.pop(context, false), ), - SizedBox(width: 10.w), - Expanded( - child: MilerButton( - label: confirmLabel, - color: ColorConstants.errorRed, - height: ButtonSizes.secondary, - onPressed: () => Navigator.pop(context, true), - ), + ), + SizedBox(width: 10.w), + Expanded( + child: MilerButton( + label: confirmLabel, + color: ColorConstants.errorRed, + height: ButtonSizes.secondary, + onPressed: () => Navigator.pop(context, true), ), - ], - ), - ], - ), + ), + ], + ), + ], ), ); } diff --git a/lib/views/Dashboard/pickups/delivery_actions.dart b/lib/views/Dashboard/pickups/delivery_actions.dart new file mode 100644 index 0000000..133f1e0 --- /dev/null +++ b/lib/views/Dashboard/pickups/delivery_actions.dart @@ -0,0 +1,710 @@ +part of 'pickups.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// CLOSING A DELIVERY +/// +/// The three ways a drop can end, and the one place that writes them. +/// +/// ── Why this is not the confirmation sheet ── +/// +/// The delivery leg used to run through the pickup flow's screens: the map +/// asked the rider to press **I've arrived**, which opened a sheet offering +/// *Picked up · Skipped · Not picked*. Both halves were wrong on a round he is +/// already carrying. Arrival at a door is not a state anything records — there +/// is no endpoint for it (see [MilkRun.deliveryArrivalIsLocalOnly]) — so +/// pressing it wrote nothing and existed only to reveal the next screen. And +/// the screen it revealed asked him to confirm a *pickup* for a bag in his box. +/// +/// So the delivery leg has no arrival step and no sheet. The stop is simply +/// **active** once he sets off, and the only question left is how it ended. +/// +/// ── Why the writes live here and not in the control that calls them ── +/// +/// The confirmation sheet already knew how to close a stop, and putting a +/// second copy behind a new button would be two implementations of one +/// business action — the thing that lets a delivery recorded from one screen +/// differ from the same delivery recorded from another. The control is a +/// control; this is what it does. +/// ───────────────────────────────────────────────────────────────────────── +enum DeliveryOutcome { + /// Handed over. `POST /miler/consignments/:id/deliver`. + delivered, + + /// Not this time — a return visit. `POST /miler/consignments/:id/skip` + /// increments `attemptcount` rather than failing the parcel. + skipped, + + /// It is not going to happen. `POST /miler/bookings/:id/cancel`. + cancelled, +} + +extension DeliveryOutcomeX on DeliveryOutcome { + String get label => switch (this) { + DeliveryOutcome.delivered => 'Delivered', + DeliveryOutcome.skipped => 'Skip', + DeliveryOutcome.cancelled => 'Cancelled', + }; + + IconData get icon => switch (this) { + DeliveryOutcome.delivered => LucideIcons.circleCheck, + // A clock, not ⏭. `skip_next` is a media-player transport glyph — "jump + // to the next track" — and on a stop it implied the order is passed + // over. A skip here is a RETURN VISIT ("keep it on board and try again + // later"), and the clock is the app's own later-glyph, the same one the + // skip-reason sheet's "Delivery paused" row already wears. + DeliveryOutcome.skipped => LucideIcons.clock, + DeliveryOutcome.cancelled => LucideIcons.circleX, + }; + + Color get colour => switch (this) { + DeliveryOutcome.delivered => ColorConstants.acceptGreen, + DeliveryOutcome.skipped => ColorConstants.warning, + DeliveryOutcome.cancelled => ColorConstants.errorRed, + }; + + /// What Activity will show once the round is over. + String get pastTense => switch (this) { + DeliveryOutcome.delivered => 'Delivered', + DeliveryOutcome.skipped => 'Skipped', + DeliveryOutcome.cancelled => 'Cancelled', + }; +} + +/// Last resort: ask the backend what this booking became. +/// +/// Returns '' when it has become nothing, which is a real answer — the booking +/// was never converted — and not a failure to look. +Future _consignmentIdFromBackend(Map stop) async { + final bookingId = (stop['bookingid'] ?? stop['orderheaderid'] ?? '') + .toString() + .trim(); + if (bookingId.isEmpty) return ''; + + try { + final assignmentId = await AssignmentLookup.idForBooking(bookingId); + if (assignmentId == null) return ''; + final res = await MilerApi.assignment(assignmentId); + if (!res.ok) return ''; + + String found = ''; + void walk(dynamic node, int depth) { + if (found.isNotEmpty || depth > 4 || node == null) return; + if (node is List) { + for (final item in node) { + walk(item, depth + 1); + } + return; + } + if (node is! Map) return; + for (final key in const [ + 'consignmentid', + 'consignmentId', + 'consignment_id', + ]) { + final v = node[key]; + if (v != null && v is! Map && v is! List) { + final str = v.toString().trim(); + if (str.isNotEmpty && str != '0' && str != 'null') { + found = str; + return; + } + } + } + for (final v in node.values) { + walk(v, depth + 1); + } + } + + walk(res.data, 0); + if (found.isEmpty) walk(res.raw, 0); + if (found.isNotEmpty) { + debugPrint( + '[DELIVERY] booking $bookingId → consignment $found (assignment)', + ); + } + return found; + } catch (e) { + debugPrint('[DELIVERY] consignment lookup failed for $bookingId: $e'); + return ''; + } +} + +/// The consignment this stop delivers against, from four sources in order of +/// freshness — or '' when the booking genuinely was never converted. +/// +/// ── Why this is a function and not a lookup ── +/// +/// `deliver` and `skip` key on the CONSIGNMENT, which only exists after +/// `pickup-complete` — and **`GET /miler/bookings` does not return it at +/// all** (verified against the live API on 2026-08-20: 22 rows, zero +/// `consignment*` keys). So the row a rider works from *never* carries the id +/// by itself, and any path that reads `stop['consignmentid']` raw refuses +/// every delivery on a fresh session. That was exactly the Update Status +/// sheet's bug: `closeDelivery` had this chain inline while the sheet read +/// the raw key, so the same door could be closed from one screen and +/// "Could not record delivered" from the other. One resolver, every caller. +/// +/// 1. the row itself, once a previous resolution has written it back; +/// 2. the id recorded at the pivot on this device, under any of the three +/// keys the pivot has historically filed it under — order id first, that +/// is the key the current write uses; +/// 3. the assignment row (`GET /miler/assignments/:id`), the one record +/// that spans both halves of the job — it knows the booking and, once +/// converted, what the booking became. One extra call at the door beats +/// refusing the rider; +/// 4. nothing, which is a real state — the pickup never converted — and gets +/// a real message rather than a network error. +/// +/// A found id is written back onto the row and into the local record, so the +/// rest of the session — and the skip path — get it for free. +Future resolveConsignmentId(Map stop) async { + final String orderId = (stop['orderid'] ?? '').toString().trim(); + + String consignmentId = (stop['consignmentid'] ?? '').toString().trim(); + if (consignmentId.isEmpty) { + final ids = await getConsignmentIds(); + for (final key in [ + orderId, + (stop['pickupid'] ?? '').toString().trim(), + (stop['bookingid'] ?? '').toString().trim(), + ]) { + if (key.isEmpty) continue; + final found = (ids[key] ?? '').trim(); + if (found.isNotEmpty) { + consignmentId = found; + break; + } + } + } + if (consignmentId.isEmpty) { + consignmentId = await _consignmentIdFromBackend(stop); + } + if (consignmentId.isNotEmpty) { + stop['consignmentid'] = consignmentId; + if (orderId.isNotEmpty) { + await rememberConsignmentId(orderId, consignmentId); + } + } + return consignmentId; +} + +/// Asks the server to release one consignment. True when it is on the road. +/// +/// ── Two servers, one button ── +/// +/// `Collected_By_Miler` and `POST /consignments/:id/start-delivery` are built +/// but **gated off** on the backend (`MILER_COLLECTED_STATE_ENABLED`, default +/// off) until this app ships the press. So both worlds are live at once and +/// this has to be right in each: +/// +/// flag OFF `pickup-complete` releases hyperlocal work itself, exactly as +/// before. The consignment is already `Out_for_Delivery` when it +/// reaches this tab, and there is nothing to call. +/// flag ON the pivot stops at `Collected_By_Miler` and this press is what +/// puts the load on the road. +/// +/// The row itself now says which — `consignmentstatus` ships on every +/// `GET /miler/bookings` row since 21 Aug 2026. So the common path costs no +/// round trip at all, and with the flag off the app never fires a +/// `start-delivery` that can only be refused: a stream of 4xx per rider press +/// is not a harmless no-op, it is the backend's error log. +/// +/// Falls back to asking the consignment when the row does not say, which is +/// also what answers the two refusals a real round produces — `INVALID_STATE` +/// (it left `Collected_By_Miler` some other way) and a lingering +/// `IDEMPOTENCY_IN_PROGRESS` after [MilerApi] has waited the write out. +/// Neither is answerable from the error itself. +Future releaseForDelivery(Map stop) async { + final consignmentId = await resolveConsignmentId(stop); + if (consignmentId.isEmpty) { + debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}'); + return false; + } + + // What the list row already told us, before spending a request on it. + var state = consignmentStateFromRaw(stop['consignmentstatus']); + if (state == ConsignmentState.unknown) { + state = await ConsignmentGate.stateOf(consignmentId); + } + if (state.isDeliverable || state.isDelivered) return true; + if (!state.needsRelease && state != ConsignmentState.unknown) { + debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable'); + return false; + } + + final res = await MilerApi.startDelivery(consignmentId); + if (res.ok) return true; + + debugPrint( + '[MILKRUN] start-delivery $consignmentId -> ' + '${res.status} ${res.code} ${res.message}', + ); + // Already on the road, or already handed over — either way this press is + // not what stands between the rider and the door. + final after = await ConsignmentGate.stateOf(consignmentId); + return after.isDeliverable || after.isDelivered; +} + +/// Brings this device into line with a delivery the server already has. +/// +/// The same local bookkeeping a successful `deliver` performs — the order +/// leaves the rider's hands, its consignment mapping is forgotten, and it is +/// filed as finished — **without** posting anything. Nothing here invents a +/// state: the state was read from the consignment's own history first. +Future _reconcileAlreadyDone( + Map stop, + _MyPickupsState? parentState, +) async { + final orderId = (stop['orderid'] ?? '').toString(); + try { + if (orderId.isNotEmpty) { + await removeCollectedOrderIds([orderId]); + await forgetConsignmentIds([orderId]); + } + // The same path a real completion takes — one recorder, so a reconciled + // stop and a delivered one produce the same Activity record. + parentState?.markPickupFinished(stop); + await addCompletedBookings([stop], terminalStatus: 'delivered'); + } catch (e) { + debugPrint('[DELIVERY] reconcile failed for $orderId: $e'); + } +} + +/// Writes [outcome] against [stop], then clears the local bookkeeping so the +/// order leaves Deliveries and lands in Activity. +/// +/// Returns the outcome payload the caller should pop with, or null when the +/// write failed — in which case the rider keeps the stop and the screen, which +/// is the only honest thing to do with work the hub has not been told about. +/// One press, one write. +/// +/// The delivery path had no shared guard — only a widget's `_isNavigating` +/// flag, which is per-widget and dies with a rebuild, and the outcome is +/// reachable from a sheet that can be re-opened. Two Delivered taps meant two +/// `deliver` posts against a backend that is not idempotent about them. Keyed +/// on the resource and the verb, so every route to the same stop shares it. +Future?> closeDelivery( + BuildContext context, { + required Map stop, + required _MyPickupsState? parentState, + required DeliveryOutcome outcome, + String notes = '', + String proofPath = '', +}) { + final key = '${outcome.name}:${(stop['orderid'] ?? '').toString()}'; + return MutationGuard.run?>( + key, + () => _closeDelivery( + context, + stop: stop, + parentState: parentState, + outcome: outcome, + notes: notes, + proofPath: proofPath, + ), + ); +} + +Future?> _closeDelivery( + BuildContext context, { + required Map stop, + required _MyPickupsState? parentState, + required DeliveryOutcome outcome, + String notes = '', + + /// Where the doorstep photo was saved, `''` when the rider completed + /// without one. Carried onto the finished record so the Activity detail + /// page can show it — see [ProofStore] for why it is not sent as + /// `photourl`. + String proofPath = '', +}) async { + final dc = Get.put(PickupsController(), permanent: true); + + final String orderId = (stop['orderid'] ?? '').toString(); + final int pickupIdInt = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0; + final int orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0; + + double parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + // The customer's door. On a milk run the stop *is* the customer's address, so + // the pickup coordinates stand in when there is no separate drop point — + // same fallback the sheet uses, for the same reason. + final double dropLat = parseD(stop['droplat'] ?? stop['DropLat'] ?? 0); + final double dropLng = parseD(stop['droplon'] ?? stop['DropLon'] ?? 0); + final double stopLat = parseD(stop['pickuplat'] ?? stop['PickupLat'] ?? 0); + final double stopLng = parseD(stop['pickuplon'] ?? stop['PickupLon'] ?? 0); + + final String consignmentId = await resolveConsignmentId(stop); + + // ── Ask the consignment what it is, before writing to it ── + // + // ROOT CAUSE, verified against the live API 2026-08-21. `deliver` answers + // `400 consignment is not out for delivery` for TWO opposite situations — + // "not released yet" and "already delivered" — and the app was treating + // both as an error to show the rider. Consignment 34's own history + // (`GET /miler/consignments/logs/34`) reads: + // + // Out_for_Delivery "Package collected by miler and converted…" + // Delivered "Delivered to SEQTEST Ukkadam at (…)" + // + // …so `pickup-complete` DOES release hyperlocal work (no + // `/miler/deliveries/start` is involved, and none exists), and that stop had + // been delivered an hour earlier. It stayed on the Deliveries tab because + // the only thing the app reads is the BOOKING status, which is terminal at + // `Converted_To_Consignment` and never learns about the delivery half — and + // the local "I delivered this" memory had gone with a reinstall. + // + // So the state is read from the one route that reports it, and the answer + // decides. See [ConsignmentGate]. + // + // Skip asks the same question and gets a different answer to one of the + // rungs: the backend widened `skip` on 21 Aug 2026 so a failed attempt is + // reportable from `Collected_By_Miler` as well as `Out_for_Delivery` — a + // customer who is not home is not home whether or not the rider remembered + // to press Start round. See [ConsignmentStateX.canSkip]. + final gated = + outcome == DeliveryOutcome.delivered || + outcome == DeliveryOutcome.skipped; + if (gated && consignmentId.isNotEmpty) { + final state = await ConsignmentGate.stateOf(consignmentId); + if (outcome == DeliveryOutcome.skipped && state.canSkip) { + // Nothing to correct and nothing to release — post it. + } else { + switch (ConsignmentGate.gateFor(state)) { + case DeliverGate.alreadyDelivered: + // The work is done server-side; this device is what is behind. File + // it and drop it from the tab — showing an error for a delivery the + // rider genuinely completed is the bug, not the fix. + debugPrint('[DELIVERY] $orderId already delivered — reconciling'); + await _reconcileAlreadyDone(stop, parentState); + if (context.mounted) { + AppFeedback.success(context, 'Already delivered — record updated'); + } + return {'outcome': 'completed', 'reconciled': true}; + + case DeliverGate.awaitingHub: + // The guard that must survive: a logistics consignment sitting at a + // hub genuinely is not this rider's to hand over. + debugPrint('[DELIVERY] $orderId is with the hub — refusing'); + if (context.mounted) { + AppFeedback.error( + context, + 'This parcel is still with the hub. It can be delivered once the ' + 'hub releases it.', + ); + } + return null; + + case DeliverGate.needsRelease: + // Collected, but nobody pressed **Start round** — a rider who left + // straight from the kitchen, or a press that failed on a dead signal. + // + // He is standing at the door. The release is his own to make and the + // route exists, so it is made here rather than sending him back two + // screens to press a button whose only purpose is to make this one + // work. Nothing is assumed: the server performs the transition and + // the state is read again before the delivery is posted. + debugPrint('[DELIVERY] $orderId not started — releasing now'); + if (!await releaseForDelivery(stop) || + !(await ConsignmentGate.stateOf(consignmentId)).isDeliverable) { + if (context.mounted) { + AppFeedback.error( + context, + 'This delivery has not been started yet. Open Deliveries and ' + 'press Start round, then try again.', + ); + } + return null; + } + break; + + case DeliverGate.closed: + debugPrint('[DELIVERY] $orderId is closed — refusing'); + if (context.mounted) { + AppFeedback.error( + context, + 'This order was closed by the office and cannot be delivered.', + ); + } + return null; + + case DeliverGate.deliverable: + case DeliverGate.unknown: + // `unknown` falls through on purpose: a failed read is not evidence, + // and the server is still the judge one call later. + break; + } + } + } + + if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) { + // Cancel is a *booking* route, so it is the one outcome that still works + // without a consignment. + // + // Reaching here means four sources came back empty, which is no longer a + // client-side gap: it means the pickup never converted this booking. The + // message says the actionable thing rather than naming an internal id the + // rider has no way to obtain. + // ── Two different failures wore one sentence ── + // + // This always said "never picked up on the system — mark it picked up + // from Home first", which is true for exactly one of the two ways to get + // here, and actively misleading for the other: + // + // • The booking really has NOT been collected. The advice is right. + // • The booking IS `Converted_To_Consignment` — collected, converted, + // sitting on the Deliveries tab — and the app simply cannot obtain the + // consignment id. Telling that rider to "pick it up from Home" sends + // him to look for a stop that is not on Home, to redo work he has + // already done, and it cannot possibly clear the error. + // + // Verified against production 2026-08-21, booking 59 + // (`DM-BK-501CB551-45130`, status `Converted_To_Consignment`): every route + // to its consignment id is a dead end — `GET /miler/bookings` returns no + // consignment key on any of its 29 rows, and `GET /miler/assignments` + // returns only 12 rows covering 12 of those bookings, so + // `AssignmentLookup` has nothing to resolve. `?status=`/`?bookingid=` + // filters are ignored by that endpoint. There is no fifth route. + // + // So the rider is told what is actually true and who can fix it, and the + // gap is logged with the evidence rather than blamed on him. The real fix + // is backend — see MILER_API_REQUIREMENTS.md request 3. + final collected = + stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg; + + debugPrint('[DELIVERY] $orderId has no consignment id — refusing'); + ApiConfig.logGap( + 'deliver', + collected + ? 'booking $orderId is collected but its consignment id is ' + 'unreachable: the row carries none, the local pivot record is ' + 'absent, and GET /miler/assignments does not list this ' + 'booking. Backend must return consignmentid on /miler/bookings.' + : 'booking $orderId reached the delivery leg with no consignment: ' + 'its pickup-complete either never ran or did not convert it.', + ); + if (context.mounted) { + AppFeedback.error( + context, + collected + ? "This order's delivery reference is missing, so it cannot be " + 'completed from the app. Ask your hub to check it — ' + 'collecting it again will not help.' + : 'This order was never picked up on the system — mark it picked ' + 'up from Home first, then deliver.', + ); + } + return null; + } + + // One fix for whichever branch runs, taken before the write so a slow GPS + // cannot delay the call the rider is waiting on. Null is fine: every route + // here treats coordinates as optional telemetry, not as a condition. + Position? riderFix; + try { + riderFix = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 5), + ), + ); + } catch (_) { + // No fix, and the outcome still has to be recordable. See above. + } + + bool ok = false; + try { + switch (outcome) { + case DeliveryOutcome.delivered: + ok = await dc.updateDeliveredStatus( + pickupId: pickupIdInt, + consignmentId: consignmentId, + deliveredToName: + (stop['dropcustomer'] ?? + stop['pickupcustomer'] ?? + stop['PickupCustomer'] ?? + 'Customer') + .toString(), + dropLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6), + dropLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6), + notes: notes, + ); + + case DeliveryOutcome.skipped: + // ── Straight to the consignment route ── + // + // Not through `PickupsController.updateSkippedStatus`, which takes an + // `int pickupId` and posts the *pickup* skip. A consignment id is not + // guaranteed to be numeric — it can be a tracking reference — so + // squeezing it through an int parameter would either lose it or, worse, + // fall back to the booking id and skip the wrong thing. + // + // The pickup-leg skip keeps that method; this is the delivery leg's. + final res = await MilerApi.skipConsignment( + consignmentId, + reason: notes.isEmpty ? 'Skipped by rider' : notes, + lat: riderFix?.latitude, + lon: riderFix?.longitude, + ); + ok = res.ok; + if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}'); + + case DeliveryOutcome.cancelled: + ok = await dc.updateCancelledStatus( + pickupId: pickupIdInt, + orderHeaderId: orderHeaderId, + pickupLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6), + pickupLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6), + notes: notes.isEmpty ? 'Cancelled by rider' : notes, + ); + } + } catch (e) { + debugPrint('[DELIVERY][${outcome.name}] $orderId failed: $e'); + ok = false; + } + + if (!ok) { + // ── Refused is not failed, and neither one may close a stop ── + // + // Completing locally after a write that never landed is what made the app + // show a finished screen while the office still had the order live. The + // rider keeps the stop and gets told why. + if (context.mounted) { + AppFeedback.error( + context, + dc.lastBlockedReason ?? + 'Could not record ${outcome.pastTense.toLowerCase()} — try again', + ); + } + return null; + } + + // ── Did the skip actually close anything? Ask, do not assume ── + // + // A delivery closes its consignment: the server says `Delivered` and the row + // stops being delivery work on its own. **A skip is a failed attempt**, and + // what the server does with one is the server's business — it may move the + // consignment to a failure state, or it may quite correctly leave it + // `Out_for_Delivery` for a second attempt or an RTO decision made elsewhere. + // + // Those two need opposite handling, and the app cannot tell them apart from + // the skip's own 200. So it reads the consignment afterwards: + // + // server closed it → a terminal record, filed in Activity, off this tab. + // server kept it open → **parked**, not closed. The stop stays visible + // under SKIPPED with its reason, the parcel stays in + // the rider's hands, and its consignment mapping is + // kept because it is still actionable. + // + // Writing a terminal local record over a consignment the hub still calls + // open is the failure this avoids: two systems disagreeing about whether a + // parcel is somebody's problem, with the rider's screen the only one saying + // it is not. See MILER_API_REQUIREMENTS.md request 14 — there is no route + // that reports a *failed attempt* as an outcome, which is why this has to be + // inferred at all. + var stillOpen = false; + if (outcome == DeliveryOutcome.skipped) { + final after = consignmentId.isEmpty + ? ConsignmentState.unknown + : await ConsignmentGate.stateOf(consignmentId); + // The rule, and why unknown counts as open, is on the state itself. + stillOpen = after.isOpenAfterSkip; + debugPrint( + '[DELIVERY][skip] $orderId -> consignment is ${after.name}, ' + '${stillOpen ? 'still open — parking' : 'closed — filing'}', + ); + if (stillOpen) { + ApiConfig.logGap( + 'skip', + 'consignment $consignmentId is still ${after.name} after a successful ' + 'skip: the backend exposes no failed-attempt outcome, so the app ' + 'parks the stop locally rather than inventing a terminal state.', + ); + } + } + + // Local bookkeeping, so the order leaves this tab immediately rather than at + // the next poll: the write has landed, and a row that lingers reads as the + // button having done nothing. + try { + if (stillOpen) { + // Parked, not finished. The parcel is still in his hands and the + // consignment is still writable, so neither record is cleared — and the + // completed store, which is what holds a stop off this tab for good, is + // deliberately not written. + await addSkippedBooking({ + ...stop, + if (proofPath.isNotEmpty) 'proofphotopath': proofPath, + }, reason: notes); + parentState?.markOrderAsSkipped(stop, notes); + unawaited(WorkRepository.instance.invalidate()); + return { + 'outcome': outcome.name, + 'isDelivery': true, + 'deliveryOutcome': outcome.name, + 'parked': true, + 'notes': notes, + }; + } + + if (orderId.isNotEmpty) { + // It is no longer in his hands, whichever way it ended. + await removeCollectedOrderIds([orderId]); + // And nothing else will be posted against its consignment. + await forgetConsignmentIds([orderId]); + } + // Records it for Activity and drops it from this tab in one call — the same + // path every other finished stop takes. `cancelled` covers both non-delivery + // outcomes, which is what Activity renders as "not completed". + parentState?.markPickupFinished( + { + ...stop, + 'orderstatus': outcome == DeliveryOutcome.delivered + ? 'delivered' + : outcome.name, + // Why he walked away, carried onto the record — Activity has nowhere + // else to get it, and "Skipped" with no reason is half a record. + if (outcome == DeliveryOutcome.skipped && notes.isNotEmpty) + 'skipreason': notes, + // The doorstep photo rides onto the record, which is the only place it + // is ever read from — Activity's detail page draws it from here. Absent + // when there is none, so a record without proof has no key rather than + // an empty one that reads as a broken image. + if (proofPath.isNotEmpty) 'proofphotopath': proofPath, + }, + cancelled: outcome != DeliveryOutcome.delivered, + // ── A skip is filed as a skip, and it does not come back ── + // + // It used to be flattened into `cancelled`, which put a stop the rider + // walked away from into the same slice as one the office called off. + // Filed under its own word it lands in Activity's *Active* slice, which + // is exactly what it is: work that still owes a return visit. + // + // Reached only when the server agrees the consignment is closed. The + // completed store is what [_MyPickupsState._restoreClosedToday] reads, + // so this stop leaves the tab and stays off it — which is only honest + // because the hub says the same thing. + terminalStatus: outcome == DeliveryOutcome.skipped ? 'skipped' : null, + ); + unawaited(ProofStore.prune()); + } catch (e) { + debugPrint('[DELIVERY][${outcome.name}] cleanup: $e'); + } + + // One copy of the day, and it is now stale — Home, Deliveries and Activity + // must all re-read rather than each deciding for itself. See [WorkRepository]. + unawaited(WorkRepository.instance.invalidate()); + + return { + 'outcome': outcome == DeliveryOutcome.delivered + ? 'completed' + : outcome.name, + 'isDelivery': true, + 'deliveryOutcome': outcome.name, + 'notes': notes, + }; +} diff --git a/lib/views/Dashboard/pickups/delivery_proof_page.dart b/lib/views/Dashboard/pickups/delivery_proof_page.dart new file mode 100644 index 0000000..9f9ee72 --- /dev/null +++ b/lib/views/Dashboard/pickups/delivery_proof_page.dart @@ -0,0 +1,358 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'package:miler/data/proof_store.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PROOF OF DELIVERY +/// +/// The last screen of a delivery: take the photo, look at it, hand over. +/// +/// ── Why capture and commit share one page ── +/// +/// The obvious build is a camera sheet that pops straight back and completes +/// the stop. It is also the one that loses parcels: the rider never sees what +/// he captured, so a thumb over the lens, a dark hallway or a blurred frame +/// becomes the evidence — and he finds out weeks later, when the office is +/// asking about a parcel he cannot prove he handed over. +/// +/// So the photo is *shown*, full width, and **Mark as delivered** sits under +/// it on the same page. One glance answers "is that good enough?", Retake is +/// one tap, and the commit is a deliberate second act rather than a side +/// effect of the shutter. +/// +/// ── The camera opens itself ── +/// +/// The rider arrived here by choosing Delivered; asking him to press another +/// button to start the thing he already asked for is a tap that buys nothing. +/// If he cancels the camera he lands on the empty state, which explains +/// itself and offers the shutter again — and, because a broken camera must +/// never trap somebody at a door, a way to complete the stop without one. +/// +/// Returns the saved proof path on success, `''` when the rider completed +/// without a photo, and null when he backed out entirely — three outcomes the +/// caller can tell apart. +/// ───────────────────────────────────────────────────────────────────────── +class DeliveryProofPage extends StatefulWidget { + /// Names the stop on the page, so the rider can see he is closing the right + /// one before he commits. + final String customer; + + /// Keys the stored photo. See [ProofStore]. + final String orderId; + + const DeliveryProofPage({ + super.key, + required this.customer, + required this.orderId, + }); + + @override + State createState() => _DeliveryProofPageState(); +} + +class _DeliveryProofPageState extends State { + String? _shotPath; + bool _busy = false; + bool _cameraFailed = false; + + @override + void initState() { + super.initState(); + // After the first frame, so the page is on screen behind the camera. + WidgetsBinding.instance.addPostFrameCallback((_) => _capture()); + } + + Future _capture() async { + if (_busy) return; + setState(() => _busy = true); + try { + final shot = await ImagePicker().pickImage( + source: ImageSource.camera, + // The same ceiling the crate photo uses: enough to identify a doorway + // and a parcel, small enough to keep on a phone with no storage left. + imageQuality: 70, + maxWidth: 1280, + ); + if (!mounted) return; + if (shot != null) setState(() => _shotPath = shot.path); + } catch (e) { + debugPrint('[PROOF] camera failed: $e'); + if (mounted) setState(() => _cameraFailed = true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Copies the shot somewhere durable and hands the path back. + Future _confirm() async { + if (_busy) return; + setState(() => _busy = true); + final saved = _shotPath == null + ? null + : await ProofStore.save(widget.orderId, _shotPath!); + if (!mounted) return; + // A failed copy must not block the hand-over — the delivery is the thing + // that matters, and the caller is told there is no proof rather than + // being handed a path that resolves to nothing. + Navigator.of(context).pop(saved ?? ''); + } + + @override + Widget build(BuildContext context) { + final hasShot = _shotPath != null; + + return Scaffold( + backgroundColor: ColorConstants.daylightSurface, + appBar: const MilerAppBar(title: 'Proof of delivery', showBack: true), + body: SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _who(), + SizedBox(height: 16.h), + hasShot ? _preview() : _empty(), + ], + ), + ), + ), + _actions(hasShot), + ], + ), + ), + ); + } + + /// Which stop this closes. Named, because the one mistake this page can + /// enable is proving the wrong delivery. + Widget _who() => GlassCard( + padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 14.h), + child: Row( + children: [ + Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.acceptGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon( + LucideIcons.user, + size: 20.sp, + color: ColorConstants.acceptGreen, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Handing over to', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + widget.customer.isEmpty ? 'Customer' : widget.customer, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + ); + + /// The shot, at the size a judgement can be made from. + Widget _preview() => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: AspectRatio( + aspectRatio: 3 / 4, + child: Image.file( + File(_shotPath!), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: ColorConstants.cardSurface, + child: Center( + child: Icon( + LucideIcons.imageOff, + size: 32.sp, + color: ColorConstants.secondaryText, + ), + ), + ), + ), + ), + ), + SizedBox(height: 12.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.circleCheck, + size: 16.sp, + color: ColorConstants.acceptGreen, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + 'Photo captured — it stays on this record', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ], + ); + + /// Before the first shot, or after the rider dismissed the camera. + Widget _empty() => GlassCard( + padding: EdgeInsets.fromLTRB(20.w, 32.h, 20.w, 32.h), + child: Column( + children: [ + Container( + width: 72.w, + height: 72.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.primary.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + _cameraFailed ? LucideIcons.imageOff : LucideIcons.camera, + size: 32.sp, + color: ColorConstants.primary, + ), + ), + SizedBox(height: 16.h), + Text( + _cameraFailed ? 'Camera unavailable' : 'Take a photo of the handover', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + _cameraFailed + ? 'You can still complete this delivery without a photo.' + : 'The parcel at the door, or with the person taking it.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.5.sp, + height: 1.45, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + + /// The commit, and the way to change your mind about the picture. + /// + /// **Mark as delivered** is the page's only filled control and it is green, + /// the colour this app gives to advancing the job. It stays enabled with no + /// photo: a camera that will not open, a dead battery on the flash, a + /// customer who objects — none of those are reasons to strand a rider at a + /// door with a parcel he has handed over. What changes is the *label under + /// it*, so completing without proof is a visible choice rather than a + /// silent one. + Widget _actions(bool hasShot) => Container( + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 16.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + border: Border(top: BorderSide(color: ColorConstants.borderSubtle)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: MilerButton( + label: hasShot ? 'Retake' : 'Open camera', + icon: hasShot ? LucideIcons.rotateCw : LucideIcons.camera, + variant: MilerButtonVariant.outlined, + color: ColorConstants.slateText, + height: ButtonSizes.primary, + onPressed: _busy ? null : _capture, + ), + ), + SizedBox(width: 10.w), + Expanded( + flex: 2, + child: MilerButton( + label: 'Mark as delivered', + icon: LucideIcons.circleCheck, + color: ColorConstants.acceptGreen, + height: ButtonSizes.primary, + loading: _busy, + onPressed: _busy ? null : _confirm, + ), + ), + ], + ), + if (!hasShot) ...[ + SizedBox(height: 8.h), + Text( + 'No photo attached to this delivery', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.warning, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ), + ); +} diff --git a/lib/views/Dashboard/pickups/done.dart b/lib/views/Dashboard/pickups/done.dart index b2349b6..0de31c1 100644 --- a/lib/views/Dashboard/pickups/done.dart +++ b/lib/views/Dashboard/pickups/done.dart @@ -349,30 +349,57 @@ class _PickupsDoneState extends State '$_remainingCount ${_remainingCount == 1 ? 'stop' : 'stops'} left', ]; - return Row( - children: [ - Icon( - isCancelled ? Icons.cancel_rounded : Icons.check_circle_rounded, - size: 16.sp, - color: isCancelled - ? ColorConstants.errorRed - : ColorConstants.acceptGreen, + // ── It still has to answer "did that register?" ── + // + // The reasoning above is right that this is completed information and must + // not out-rank the next stop. It went one step too far: a 16sp tick beside + // 12.5sp grey text is the same treatment as every other caption on the + // screen, so the one line confirming the act the rider just performed + // looked like metadata about it. + // + // A tinted capsule — the app's own state vocabulary, the same one the route + // chips and the sheet's secondary actions use — gives it an edge and its + // semantic colour back without giving it size. It is still shorter and + // quieter than the next-stop hero underneath, which is the ranking that + // matters. + final Color tone = isCancelled + ? ColorConstants.errorRed + : ColorConstants.acceptGreen; + + return Align( + alignment: AlignmentDirectional.centerStart, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.h), + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), - SizedBox(width: 7.w), - Expanded( - child: Text( - parts.join(' · '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isCancelled ? LucideIcons.circleX : LucideIcons.circleCheck, + size: 18.sp, + color: tone, ), - ), + SizedBox(width: 8.w), + Flexible( + child: Text( + parts.join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: tone, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], ), - ], + ), ); } @@ -389,7 +416,8 @@ class _PickupsDoneState extends State /// asks what he will be doing when he arrives. A saturated chip at the top /// asked that question first and in the loudest voice on the screen. Widget _nextStopHero(Map stop) { - final kind = stopKindOf(stop); + // The leg, not the payload — see [MilkRun.workingKind]. + final kind = MilkRun.workingKind(stop); final name = (stop['pickupcustomer'] ?? 'Stop').toString(); final address = (stop['pickupaddress'] ?? 'Address not available') .toString(); @@ -408,7 +436,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 28.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.9, height: 1.08, color: ColorConstants.slateText, @@ -450,7 +478,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: kind.accent, fontFamily: FontConstants.fontFamily, ), @@ -478,11 +506,7 @@ class _PickupsDoneState extends State return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.replay_rounded, - size: 15.sp, - color: ColorConstants.warning, - ), + Icon(LucideIcons.rotateCcw, size: 15.sp, color: ColorConstants.warning), SizedBox(width: 8.w), Expanded( child: Text( @@ -490,7 +514,7 @@ class _PickupsDoneState extends State style: TextStyle( fontSize: 12.5.sp, height: 1.35, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), @@ -541,7 +565,8 @@ class _PickupsDoneState extends State } Widget _upNextRow(Map stop, int position) { - final kind = stopKindOf(stop); + // The leg, not the payload — see [MilkRun.workingKind]. + final kind = MilkRun.workingKind(stop); final name = (stop['pickupcustomer'] ?? 'Stop').toString(); final address = (stop['pickupaddress'] ?? '').toString(); @@ -562,8 +587,8 @@ class _PickupsDoneState extends State child: Text( '$position', style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontSize: 13.sp, + fontWeight: FontWeight.w700, color: kind.accent, fontFamily: FontConstants.fontFamily, ), @@ -580,7 +605,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -605,7 +630,7 @@ class _PickupsDoneState extends State ), SizedBox(width: 8.w), Icon( - Icons.chevron_right_rounded, + LucideIcons.chevronRight, size: 20.sp, color: ColorConstants.secondaryText, ), @@ -636,7 +661,7 @@ class _PickupsDoneState extends State 'Finding your next stop…', style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -666,7 +691,7 @@ class _PickupsDoneState extends State shape: BoxShape.circle, ), child: Icon( - Icons.warehouse_rounded, + LucideIcons.warehouse, size: 30.sp, color: ColorConstants.acceptGreen, ), @@ -677,7 +702,7 @@ class _PickupsDoneState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 24.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.6, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -725,8 +750,16 @@ class _PickupsDoneState extends State ? 'Loading…' : hasNext ? 'Move to next stop' - : 'Return to hub', - icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded, + // A milk run ends at the last door, not at a depot — see + // [ServiceProfile.endsAtHub]. Capability, not screen position. + : (ServiceProfile.active.endsAtHub + ? 'Return to hub' + : 'Finish my round'), + icon: hasNext + ? LucideIcons.arrowRight + : (ServiceProfile.active.endsAtHub + ? LucideIcons.warehouse + : LucideIcons.house), color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary, loading: _loadingNextOrders, onPressed: _loadingNextOrders ? null : () => _goToNextStop(), @@ -738,16 +771,19 @@ class _PickupsDoneState extends State /// Section labels. Small, letterspaced, grey — the quietest way to name a /// group, and the reason none of the groups needs an outline. TextStyle get _eyebrow => TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w800, - letterSpacing: 1.3, + // 11 -> 12.5, and less tracking. A letterspaced 11sp all-caps label is the + // least readable combination in the app, on the screen a rider glances at + // between two stops. + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0.9, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ); TextStyle get _metaStyle => TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ); @@ -779,11 +815,7 @@ class _PickupsDoneState extends State color: ColorConstants.rewardAmber, shape: BoxShape.circle, ), - child: Icon( - Icons.card_giftcard_rounded, - size: 30.sp, - color: Colors.white, - ), + child: Icon(LucideIcons.gift, size: 30.sp, color: Colors.white), ), ), @@ -796,7 +828,7 @@ class _PickupsDoneState extends State 'You Won a Bonus!', style: TextStyle( fontSize: 24.sp, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -869,7 +901,7 @@ class _PickupsDoneState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.monetization_on_rounded, + LucideIcons.circleDollarSign, size: 64.sp, color: const Color(0xFFFFB800), ), @@ -878,7 +910,7 @@ class _PickupsDoneState extends State '${widget.bonusPoints}', style: TextStyle( fontSize: 52.sp, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, letterSpacing: -2, @@ -888,7 +920,7 @@ class _PickupsDoneState extends State 'Bonus Points', style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), @@ -909,7 +941,7 @@ class _PickupsDoneState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.touch_app_rounded, + LucideIcons.pointer, color: ColorConstants.onSurfaceVariant, size: 16.sp, ), @@ -945,8 +977,16 @@ class _PickupsDoneState extends State Widget _buildDoneButton({required bool isEnabled}) { final bool hasNext = _nextOrders.isNotEmpty; return MilerButton( - label: hasNext ? 'Move to next stop' : 'Return to hub', - icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded, + label: hasNext + ? 'Move to next stop' + : (ServiceProfile.active.endsAtHub + ? 'Return to hub' + : 'Finish my round'), + icon: hasNext + ? LucideIcons.arrowRight + : (ServiceProfile.active.endsAtHub + ? LucideIcons.warehouse + : LucideIcons.house), color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary, onPressed: isEnabled ? () => _goToNextStop() : null, ); diff --git a/lib/views/Dashboard/pickups/map.dart b/lib/views/Dashboard/pickups/map.dart index 2a457f3..4302f75 100644 --- a/lib/views/Dashboard/pickups/map.dart +++ b/lib/views/Dashboard/pickups/map.dart @@ -141,6 +141,14 @@ class _PickupMapScreenState extends State<_PickupMapScreen> /// grey over it turns muddy at the alpha the halo runs at. static const Color _flowHaloColour = Color(0xFFA89E9E); + /// Whether the platform has asked for reduced motion. + /// + /// Read from the widget tree rather than cached, because a rider can change + /// it in system settings while this screen is open — and the looping stream + /// is exactly the kind of thing somebody turns that setting on to stop. + bool get _reducedMotion => + MediaQuery.maybeDisableAnimationsOf(context) ?? false; + /// How fast the stream travels, in metres of real route per second of /// animation. ~1.9 km/s puts an ordinary 3 km leg at a little over a second /// and a half, which is a glance rather than something waited out. @@ -166,9 +174,18 @@ class _PickupMapScreenState extends State<_PickupMapScreen> /// The bright stroke, at its brightest. 0.62 was why it "looked very lightly" /// — over a 6pt brand-red line a 62% white is a pink smear. - static const double _flowCoreAlpha = 0.96; + /// ── Toned down: a direction cue, not something to watch ── + /// + /// This ran at 0.96 — effectively solid white — over a brand-red route on a + /// pale map. Held at arm's length the brightest, fastest-moving thing on the + /// screen was the animation, not the destination pin and not the ETA, and a + /// rider's eye goes to movement before it goes to anything else. + /// + /// The stream still says *this way*, which is its whole job. It just no + /// longer says it louder than the two facts the screen exists to deliver. + static const double _flowCoreAlpha = 0.55; - static const double _flowHaloAlpha = 0.42; + static const double _flowHaloAlpha = 0.24; static Color get kBackground => ColorConstants.surfaceContainerLow; static Color get kTextPrimary => ColorConstants.onSurface; static const Color kCardBg = Colors.white; @@ -389,7 +406,21 @@ class _PickupMapScreenState extends State<_PickupMapScreen> final bool hasPickup = pickLat != 0 && pickLon != 0; final bool hasDrop = dropLat != 0 && dropLon != 0; - _pickupLocation = hasPickup + // ── Which end of the journey this screen is about ── + // + // `_pickupLocation` is the stop the rider is being sent to, and on a milk + // run that is not always the pickup. Once an order is collected the next + // journey is to the **customer**, and opening the kitchen again would send + // him back for a bag already in his box. + // + // The rule lives in [MilkRun.navigatesToCustomer] so this screen, the + // detail sheet and the card cannot disagree. A logistics stop is never + // affected: its collected parcel goes to a hub, not to a door. + final bool toCustomer = MilkRun.navigatesToCustomer(d); + + _pickupLocation = (toCustomer && hasDrop) + ? LatLng(dropLat, dropLon) + : hasPickup ? LatLng(pickLat, pickLon) : (riderLat != 0 && riderLon != 0 ? LatLng(riderLat, riderLon) @@ -420,7 +451,25 @@ class _PickupMapScreenState extends State<_PickupMapScreen> /// to compete with map tiles. The pins mark the ends; the sheet says what /// they are. void _setMarkers() { - final kind = stopKindOf(widget.pickup); + // The leg, not the payload — see [MilkRun.workingKind]. + final kind = MilkRun.workingKind(widget.pickup); + + // ── One pin per PLACE, not per concept ── + // + // On the delivery leg the navigation target IS the drop, so the accent + // pin and the green flag were the same coordinates twice — two teardrops + // stacked into one smudge at one end of the route, while the route's + // other end (the rider) had no marker at all. The flag now appears only + // when the parcel's final drop is genuinely a different place from the + // stop being navigated to (a logistics leg whose payload carries both); + // the rider's own end is marked live in [_liveMarkers]. + final double gap = RouteMetricsHelper.distanceMeters( + _pickupLocation.latitude, + _pickupLocation.longitude, + _dropLocation.latitude, + _dropLocation.longitude, + ); + _markers ..clear() ..addAll([ @@ -437,18 +486,38 @@ class _PickupMapScreenState extends State<_PickupMapScreen> emphasised: true, ), ), - milerMarker( - point: _dropLocation, - width: MilerPin.size + 8, - height: MilerPin.size + MilerPin.tail, - child: MilerPin( - color: ColorConstants.acceptGreen, - icon: Icons.flag_rounded, + if (gap > 30) + milerMarker( + point: _dropLocation, + width: MilerPin.size + 8, + height: MilerPin.size + MilerPin.tail, + child: MilerPin( + color: ColorConstants.acceptGreen, + icon: LucideIcons.flag, + ), ), - ), ]); } + /// The stored pins plus the rider's own dot, read fresh each build so the + /// dot rides the position stream. The route line used to run from the + /// rider's fix to the stop with a pin at only ONE of those ends — a stroke + /// leaving the frame into nothing is the map claiming the journey starts + /// nowhere. + List _liveMarkers() { + final live = _livePosition; + return [ + ..._markers, + if (live != null) + milerMarker( + point: LatLng(live.latitude, live.longitude), + width: MilerRiderDot.size, + height: MilerRiderDot.size, + child: const MilerRiderDot(), + ), + ]; + } + /// Swaps the stock teardrops for callouts carrying the address. /// /// ── Why a bitmap and not a widget ── @@ -695,6 +764,21 @@ class _PickupMapScreenState extends State<_PickupMapScreen> void _restartFlow() { if (!mounted || _routePoints.length < 2) return; + // ── Reduced motion means no loop at all ── + // + // A stream that runs for as long as the screen is open is the one thing on + // here a rider with a vestibular disorder cannot look away from — the map + // is the screen. When the platform asks for reduced motion the route is + // simply drawn, complete and still: every piece of navigation information + // survives, and the only thing lost is the part that was decoration. + if (_reducedMotion) { + _flowAnim.stop(); + _flowHalo = null; + _flowCore = null; + _mapRepaint.value++; + return; + } + final growMs = (_routeMetres / _flowMetresPerSecond * 1000) .clamp(_flowGrowMinMs, _flowGrowMaxMs) .round(); @@ -832,11 +916,23 @@ class _PickupMapScreenState extends State<_PickupMapScreen> /// available as soon as the map is in the tree, so there is nothing to retry. Future _fitMapToRoute() async { if (!mounted) return; + // ── The frame is measured from the sheet, not from a remembered number ── + // + // `300.h` was the sheet's height on one device. On a 320×568 phone it is + // more than half the viewport and the route was framed into a sliver; at + // 2.0× text, where the sheet has to grow, it was short and the stop pin + // ended up underneath it. Both are the same bug: two numbers describing + // one thing, kept in step by hand. + // + // Derived instead — the working extent of the sheet plus a margin, and the + // top inset clears the status bar and the Back control rather than + // assuming 90 does. + final media = MediaQuery.of(context); + final viewport = media.size.height; + final top = media.padding.top + 72; await _camera.fit( [_routeStart, _routeEnd], - // Bottom-heavy: the sheet covers the lower 45% of the screen, so a route - // centred in the *viewport* would sit centred under the sheet. - padding: EdgeInsets.fromLTRB(48, 90, 48, 300.h), + padding: EdgeInsets.fromLTRB(48, top, 48, viewport * _sheetWorking + 24), ); } @@ -861,7 +957,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen> initialCenter: _pickupLocation, initialZoom: 14, polylines: _routeLayers, - markers: _markers, + markers: _liveMarkers(), onReady: () { if (!_isLoadingRoute && _routeLayers.isNotEmpty) { _fitMapToRoute(); @@ -870,10 +966,57 @@ class _PickupMapScreenState extends State<_PickupMapScreen> ), ), ), + // ── The map is never blanked while it is still useful ── + // + // This dimmed the whole screen behind a spinner while the road route + // loaded. But the map is already showing the right place, the stop + // pin is already on it, and if routing fails the screen falls back + // to a dashed direct line and carries on — so at no point is the + // view actually unusable. Dimming it said "wait" about something the + // rider did not have to wait for, on the one screen he opens while + // moving. + // + // A small mark at the top edge instead: enough to explain why the + // line has not appeared, small enough to ignore. if (_isLoadingRoute) - Container( - color: Colors.black.withValues(alpha: 0.1), - child: Center(child: CircularProgressIndicator(color: kPrimary)), + Positioned( + top: MediaQuery.of(context).padding.top + 20, + right: 20.w, + child: IgnorePointer( + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 8.h, + ), + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 12.w, + height: 12.w, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(kPrimary), + ), + ), + SizedBox(width: 8.w), + Text( + 'Finding the route', + style: MilerType.micro.copyWith( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), ), _buildHeader(), _buildDraggableBottomSheet(), @@ -902,16 +1045,33 @@ class _PickupMapScreenState extends State<_PickupMapScreen> ), ], ), - child: IconButton( - tooltip: 'Back', - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back_ios_new, - color: kTextPrimary, - size: 16, + // ── The one control over the map, at a real size ── + // + // `constraints: BoxConstraints()` removes `IconButton`'s own 48×48 + // floor, and with a 16pt glyph and 12pt padding the target was + // 40×40 — under the accessibility minimum, on a control pressed + // with a glove, at a junction, one-handed. The *visible* circle + // can stay compact; the thing the finger has to find cannot. + // + // The glyph goes up to 18 as well: 16 is small for the only + // affordance on a full-screen map. + child: Semantics( + button: true, + label: 'Back to the queue', + child: IconButton( + tooltip: 'Back', + onPressed: () => Navigator.pop(context), + icon: Icon( + LucideIcons.chevronLeft, + color: kTextPrimary, + size: 18, + ), + padding: const EdgeInsets.all(12), + constraints: BoxConstraints( + minWidth: ButtonSizes.minTapTarget, + minHeight: ButtonSizes.minTapTarget, + ), ), - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(), ), ), // The floating "Pickup Location" / "Delivery Location" pill that used @@ -928,11 +1088,32 @@ class _PickupMapScreenState extends State<_PickupMapScreen> ); } + /// The three resting heights of the sheet, as fractions of the viewport. + /// + /// ── Why these are constants and not literals at the call site ── + /// + /// The camera has to know how much of the screen the sheet covers, or it + /// frames a route into the space the sheet is sitting on. That was a + /// hardcoded `300.h` in `_fitMapToRoute` — a number that agreed with `0.45` + /// only on the phone it was measured on, and silently disagreed on every + /// other height, and would have gone on disagreeing after any change to the + /// sheet. + /// + /// Three stops and no more: a glance, the working height, and the detail + /// view. A drag with many arbitrary rest points feels loose and gives the + /// rider nothing to aim at. + static const double _sheetGlance = 0.35; + static const double _sheetWorking = 0.45; + static const double _sheetDetail = 0.75; + Widget _buildDraggableBottomSheet() { return DraggableScrollableSheet( - initialChildSize: 0.45, - minChildSize: 0.35, - maxChildSize: 0.75, + initialChildSize: _sheetWorking, + minChildSize: _sheetGlance, + maxChildSize: _sheetDetail, + // Snap to the three, so the sheet always settles somewhere meant. + snap: true, + snapSizes: const [_sheetGlance, _sheetWorking, _sheetDetail], // ── Frosted, not opaque ── // // A solid white slab over a map takes away 45% of the one thing this @@ -942,24 +1123,22 @@ class _PickupMapScreenState extends State<_PickupMapScreen> builder: (context, scrollController) => milerGlassSheet( child: Column( children: [ - const SizedBox(height: 12), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: ColorConstants.borderSubtle, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - ), + const MilerSheetHandle(), Expanded( // Content settles in (fade + gentle rise) as the route finishes // loading, so the sheet feels alive alongside the drawing line. + // 500ms was long enough to notice on a screen the rider opens + // between stops — the route's own entrance is allowed to take + // its time because it is *explaining* the line appearing, but + // ordinary content settling should be over before he has + // finished looking at it. `motionState` is the app's own token + // for a state change in place. child: AnimatedSlide( - duration: const Duration(milliseconds: 500), + duration: DesignConstants.motionState, curve: Curves.easeOutCubic, offset: _isLoadingRoute ? const Offset(0, 0.06) : Offset.zero, child: AnimatedOpacity( - duration: const Duration(milliseconds: 500), + duration: DesignConstants.motionState, opacity: _isLoadingRoute ? 0.0 : 1.0, // ── One sheet, not a stack of cards ── // @@ -1018,34 +1197,51 @@ class _PickupMapScreenState extends State<_PickupMapScreen> /// /// The block itself is [_StopBrief], shared with Update Status and Skip. Only /// the status pill is local to this screen. + /// ── The status chip is gone; the phone takes its place ── + /// + /// The chip printed the stop's rung — `Picked up`, `Accepted` — in the + /// trailing corner of the identity row, and it was the wrong fact in the + /// wrong place twice over. The rider is *on this screen because* of that + /// rung; the sheet he opened, the button at the foot of it and the map behind + /// it all say the same thing. And stacking the call button under it left the + /// two on different centre lines, so the corner never lined up with the name + /// it sits beside. + /// + /// One control in that corner, vertically centred against the identity block: + /// the way to ring the person whose address is on the left. Calling is the + /// secondary operational action on this screen — navigation and the state + /// transition stay primary at the foot — so it is a quiet green tile, not a + /// labelled button. + /// + /// Omitted entirely when the stop carries no number, rather than shown + /// disabled: a dead control in a corner is worse than an empty corner. Widget _buildStopBrief() { - final status = (widget.pickup['orderstatus'] ?? '') - .toString() - .toUpperCase(); + // ── The contact belongs to the leg, not to a key name ── + // + // This read `pickupcontactno` first on every leg, so a rider standing at a + // customer's door was offered a control the app announced as "Call + // customer" while its precedence was written for a kitchen. On today's + // payload there is only one number and it happens to be right both ways + // (see [StopContact]) — which is exactly the kind of accident that stops + // being an accident the day a drop contact ships. + // + // `MilkRun.navigatesToCustomer` is the same reader the destination, the + // route and the copy on this screen already use, so the number, the label + // and the pin cannot disagree about which leg this is. + final contact = StopContact.forLeg( + widget.pickup, + delivery: MilkRun.navigatesToCustomer(widget.pickup), + ); return _StopBrief( pickup: widget.pickup, - trailing: Flexible( - child: Container( - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), - decoration: BoxDecoration( - color: ColorConstants.acceptGreen.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - ), - child: Text( - status.isNotEmpty ? status : 'PENDING', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: FontConstants.fontFamily, - color: ColorConstants.acceptGreen, - fontSize: 10.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.3, + trailing: contact.isEmpty + ? null + : _CallButton( + phone: contact.number, + size: 46.w, + semanticLabel: contact.action, ), - ), - ), - ), ); } @@ -1070,6 +1266,33 @@ class _PickupMapScreenState extends State<_PickupMapScreen> ); } + // ── A delivery has no arrival step ── + // + // On the delivery leg this row used to be Navigate + **I've arrived**, and + // the arrival half was doing nothing: there is no endpoint that records + // reaching a customer's door (see [MilkRun.deliveryArrivalIsLocalOnly]), so + // the button wrote nothing and existed only to reveal a confirmation sheet + // — which then asked the rider to confirm a *pickup* for a bag already in + // his box. + // + // Once he sets off the stop is simply **active**. The only question left is + // how it ended, and that is one deliberate control: slide to open, then + // Delivered / Skip / Cancelled. See [_DeliveryStatusSlider]. + if (MilkRun.workingKind( + widget.pickup, + collectedIds: widget.parentState?._collectedIds ?? const {}, + ).isDelivery) { + return _DeliveryStatusSlider( + pickup: widget.pickup, + onNavigate: () { + _hasOpenedNavigation = false; + _openGoogleMapsNavigation(); + }, + onChoose: (outcome, {String proofPath = ''}) => + _closeDelivery(outcome, proofPath: proofPath), + ); + } + // Two clear actions: re-launch navigation (in case the rider dismissed // Google Maps) and confirm arrival. Slides are reserved for money. // @@ -1084,6 +1307,21 @@ class _PickupMapScreenState extends State<_PickupMapScreen> // to re-open Maps, not the thing this screen is for — but it takes the same // green so the two read as one family rather than as two unrelated // decisions. + // ── Calling the customer, from the screen he is driving on ── + // + // The number was reachable from the booking card and from the Home stop + // sheet, and then not from this screen — the one the rider is actually + // looking at between accepting a stop and reaching it, and the exact moment + // he needs it: a gate code, a wrong door, "I'm outside". Getting to it meant + // leaving the map, going back to the list and finding the row again. + // + // Same control as the card's, so it looks and behaves identically wherever + // he finds it — neutral rather than green, because calling is an affordance, + // not a state. See [_CallButton]. + // + // Call has moved up to the identity block — see `_buildStopBrief`. This row + // is about the journey, and two controls that both concern it read better + // than three that do not. return SizedBox( height: ButtonSizes.primary, width: double.infinity, @@ -1092,10 +1330,22 @@ class _PickupMapScreenState extends State<_PickupMapScreen> Expanded( flex: 4, child: MilerButton( + // ── Navigation is brand, and brand is red ── + // + // It was an outlined green button beside a filled green one — two + // controls in one hue where one takes the rider somewhere and the + // other closes the job, distinguished only by whether they were + // filled. On a map screen, at arm's length, that is not a + // distinction. + // + // The app's own rule already said where this belongs: *green + // advances the job, maroon is brand and navigation*. Navigate is + // navigation. Filled, because on the screen the rider opens to + // drive somewhere it is the primary action, and the pair now + // differs in colour rather than in weight. label: 'Navigate', - icon: Icons.navigation_rounded, - variant: MilerButtonVariant.outlined, - color: ColorConstants.acceptGreen, + icon: LucideIcons.navigation, + color: ColorConstants.primary, onPressed: () { _hasOpenedNavigation = false; _openGoogleMapsNavigation(); @@ -1106,11 +1356,23 @@ class _PickupMapScreenState extends State<_PickupMapScreen> Expanded( flex: 6, child: MilerButton( + // ── It says he is there, not what happened ── + // + // This read "I've handed over" on a meal run, and then opened a + // sheet asking whether the food was delivered, not delivered or + // skipped. The button had already answered its own question, so + // the two disagreed whenever the answer was anything but the + // happy one: a rider at a locked gate had to press *I've handed + // over* to be able to report that he had not. + // + // One label for both lines now. Arriving is a fact he knows at + // the moment he presses it; the outcome is the next screen's + // question, and it is the only question that screen asks. label: "I've arrived", - icon: Icons.check_circle_rounded, + icon: LucideIcons.circleCheck, color: ColorConstants.acceptGreen, onPressed: () async { - debugPrint('[ARRIVED] Tapped "I have arrived" on map screen'); + debugPrint('[ARRIVED] Tapped arrival on map screen'); await _handleArrived(); }, ), @@ -1120,19 +1382,129 @@ class _PickupMapScreenState extends State<_PickupMapScreen> ); } + /// Ends the delivery, then leaves the screen with the outcome. + /// + /// The write and the local bookkeeping are [closeDelivery]'s — the same path + /// the confirmation sheet uses for a pickup — so a delivery closed from here + /// and one closed from anywhere else cannot record different things. This + /// method is only the navigation around it. + Future _closeDelivery( + DeliveryOutcome outcome, { + String proofPath = '', + }) async { + if (_isNavigating) return; + setState(() => _isNavigating = true); + + final navigator = Navigator.of(context); + final result = await closeDelivery( + context, + stop: widget.pickup, + parentState: widget.parentState, + outcome: outcome, + proofPath: proofPath, + ); + + if (result == null) { + // Refused or failed — [closeDelivery] has already said why, and the rider + // keeps the stop and this screen. + if (mounted) setState(() => _isNavigating = false); + return; + } + + // Said after the pop, and context-free, because the screen that would have + // shown it is the one being closed. Without it the row simply vanishes from + // the list, which reads as the tap having lost the stop rather than + // finished it. + navigator.pop(result); + AppFeedback.successGlobal( + '${outcome.pastTense} · ${MilkRun.sourceNameOf(widget.pickup)}', + ); + } + Future _handleArrived() async { if (_handlingArrival || _isNavigating || !mounted) return false; _handlingArrival = true; try { - final verifyResult = await openSheet>( - context, - StopVerificationPage(pickup: widget.pickup), - ); + // ── What arriving asks for depends on the line ── + // + // On a **milk run** it asks for nothing. The rider is at a door with one + // bag that was paid for by subscription; the proof-of-work page — parcel + // ticks, weight, condition, OTP, photo, review — is a two-minute form + // standing between him and a doorbell, and the confirmation sheet at the + // end of this method already asks the one question the stop has an answer + // to. The result keeps the page's shape so nothing downstream changes. + // + // On **logistics** it asks for everything, because this is the moment the + // shipment comes into existence: what is in the parcel, where it is + // going, what it weighs, what it costs. Skipping it is what left the + // backend routing consignments from a pin the customer dropped last week + // and billing them on an estimate nobody checked. + Map verifyResult = {'verified': true}; - if (verifyResult == null || - verifyResult['verified'] != true || - !mounted) { + if (ServiceProfile.active.needsVerification) { + final verified = await openScreen>( + context, + StopVerificationPage(pickup: widget.pickup), + ); + // Backed out of the form: he is not finishing this stop, and the map + // stays exactly where it was. + if (verified == null || verified['verified'] != true) { + _handlingArrival = false; + return false; + } + verifyResult = verified; + } + + if (!mounted) { + _handlingArrival = false; + return false; + } + + // ── The shipment desk ── + // + // FROM · TO · WEIGHT · DISTANCE · PRICE, in one conversation with the + // customer. Its answers ride along in the same map, and + // `_startPickupNavigation` sends them — addresses first, because + // `pickup-complete` routes the consignment from them. + if (ServiceProfile.active.capturesShipmentAddresses) { + final seeded = Map.from(widget.pickup); + // Don't ask for the weight twice: the verification page has just taken + // it, so the desk opens with it filled in. + final weighed = (verifyResult['weight'] ?? '').toString().trim(); + if (weighed.isNotEmpty) seeded['weight'] = weighed; + + final captured = await openScreen>( + context, + ShipmentCapturePage(pickup: seeded), + ); + if (captured == null || captured['captured'] != true) { + _handlingArrival = false; + return false; + } + verifyResult = {...verifyResult, 'shipment': captured}; + + // ── The quote becomes the amount due ── + // + // Stamped onto the stop rather than threaded through the sheet as a + // new argument, because `collectionamt` is already the one field every + // downstream reader consults for "what does the customer owe?" — + // `stopCollectionAmount`, the payment page's total, the short-payment + // path, the completion record. Writing the quote there means the fare + // the rider showed the customer is the fare the payment screen asks + // for, with no second source to drift from it. + // + // A booking that arrived with an estimate is overwritten deliberately: + // the estimate was computed from what the customer guessed, and the + // rider has just weighed it. + final quoted = (captured['price'] as num?)?.toDouble() ?? 0; + if (quoted > 0) { + widget.pickup['collectionamt'] = quoted; + widget.pickup['shipmentquote'] = captured; + } + } + + if (!mounted) { _handlingArrival = false; return false; } @@ -1149,6 +1521,91 @@ class _PickupMapScreenState extends State<_PickupMapScreen> return true; } + /// Takes the money, then creates the shipment. Returns null on success, or a + /// message the review page shows without leaving the screen. + /// + /// ── The order is the contract's, not a preference ── + /// + /// `pickup-complete` is the pivot: it converts the booking into a consignment + /// and mints a tracking number. A payment recorded against a booking that has + /// already become a consignment has nothing to attach to — so the money goes + /// first, every time. + /// + /// ── Why a failure here must not look like success ── + /// + /// Almost every other status write in this app is optimistic, because a rider + /// who watches a completed stop bounce back stops trusting the button. This + /// one is the exception: it is the moment a shipment comes into existence and + /// the moment cash changes hands. Playing the "order created" animation over + /// a failed create would send the rider away believing an order exists, with + /// the customer's money in his pocket and nothing on the hub's screen. + Future _payAndCreateOrder({ + required int pickupId, + required int orderHeaderId, + required ShipmentPayMethod method, + required double amount, + required String riderLat, + required String riderLng, + required String orderId, + }) async { + final dc = Get.put(PickupsController(), permanent: true); + + // ── Step 3: the money ── + // + // Skipped only when nothing is owed. `submitPayment` refuses a zero amount + // server-side anyway, and a prepaid shipment still has to be created. + if (amount > 0) { + final payRes = await UpdatePickupProvider().submitPayment(pickupId, { + 'paid': true, + 'method': method.wireName, + 'amountCollected': amount, + }); + if (payRes?['status'] != true) { + return 'Payment could not be recorded — ' + '${(payRes?['message'] ?? 'check your connection and try again')}'; + } + } + + // ── Step 4: the shipment exists from here ── + // + // Routed through the controller rather than `MilerApi.pickupComplete` + // directly, so the geofence, the rider-kilometre calculation and the + // punctuality bonus all still run — and so `lastTrackingNo` is populated + // for the receipt on the success screen. + final ok = await dc.updatePickedupStatus( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + pickupLocationId: + int.tryParse('${widget.pickup['pickuplocationid'] ?? 0}') ?? 0, + smsPickup: 0, + ridersLat: riderLat, + ridersLng: riderLng, + pickupLat: _parseD( + widget.pickup['pickuplat'] ?? widget.pickup['PickupLat'], + ).toStringAsFixed(6), + pickupLng: _parseD( + widget.pickup['pickuplon'] ?? widget.pickup['PickupLon'], + ).toStringAsFixed(6), + notes: 'Order initiated', + collectionAmount: amount, + collectedAmount: amount, + collectionStatus: amount <= 0 + ? 0 + : (method == ShipmentPayMethod.upi ? 2 : 1), + orderId: orderId, + proofImage: '', + ); + + if (!ok) { + // Refused, not failed: he is not at the address, so nothing was sent. + // The geofence message is the useful one — it says how far off he is. + final blocked = dc.lastBlockedReason; + return blocked ?? + 'The order could not be created. Check your connection and try again.'; + } + return null; + } + Future _startPickupNavigation( Map verificationData, ) async { @@ -1243,15 +1700,35 @@ class _PickupMapScreenState extends State<_PickupMapScreen> // Not fatal if it fails: the stop still completes and bills on the // customer's booked estimate instead of the measured figure. Blocking the // rider at a doorstep over a billing detail would be the worse trade. - if (pickupId > 0) { - final parcelRes = await UpdatePickupProvider().submitParcels( + // ── Step 1b, and it must go FIRST ── + // + // `pickup-complete` (step 4) builds the consignment from the booking's + // addresses — its routing hub and its pricing zone both come from them — + // and the handler refuses an address change once that conversion has + // happened. So the FROM and TO the rider just confirmed have exactly one + // window in which they can land, and it is here, before anything else. + // + // Unlike the parcel step below, a failure here is worth stopping for: the + // shipment would be routed and priced from an address the rider has just + // been told is wrong, and neither he nor the customer would ever see the + // discrepancy. + final shipment = verificationData['shipment']; + if (pickupId > 0 && shipment is Map) { + final addrRes = await UpdatePickupProvider().submitAddresses( pickupId, - verificationData, + shipment, ); - if (parcelRes?['status'] != true) { - debugPrint( - '[PARCEL] booking $pickupId: not recorded — $parcelRes', - ); + if (addrRes?['status'] != true) { + debugPrint('[ADDRESS] booking $pickupId: not recorded — $addrRes'); + if (mounted) { + AppFeedback.error( + context, + 'Could not save the addresses — the shipment would be routed to ' + 'the wrong place. Check your connection and try again.', + ); + } + _endNavigating(); + return; } } @@ -1260,23 +1737,74 @@ class _PickupMapScreenState extends State<_PickupMapScreen> return; } - // Milk-run model: there is no per-stop drop leg. After verifying the - // parcel the rider just confirms this pickup and moves to the next - // stop — so open the confirm/payment sheet directly instead of a - // redundant "navigate to drop" screen. On completion we return to the - // pickup list (a confirmed stop drops off; a dismissed one stays for - // retry). - final dynamic result = await showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (sheetContext) => - _PickupBottomSheet(pickup: widget.pickup, parentState: parentState), - ); + if (pickupId > 0) { + final parcelRes = await UpdatePickupProvider().submitParcels( + pickupId, + verificationData, + ); + if (parcelRes?['status'] != true) { + debugPrint('[PARCEL] booking $pickupId: not recorded — $parcelRes'); + } + } + + if (!mounted) { + _endNavigating(); + return; + } + + // ── Two ways to close a stop, and they are genuinely different jobs ── + // + // **Logistics** ends by *creating a shipment*: the rider has captured the + // addresses, the weight and the fare, so the last screen is one review of + // all of it with the amount on the button — `Pay ₹150` — and the order is + // raised when he presses it. That used to be a confirm sheet plus a + // separate payment page plus the verify page's own review: three screens + // showing the same facts, with the number he quoted and the number he + // collected two taps apart. + // + // **A milk run** ends by *confirming a hand-over*. There is no money, no + // shipment to raise and nothing to price, so it keeps the sheet, which + // asks the one question that stop has an answer to. + final Map? shipmentCapture = + verificationData['shipment'] is Map + ? verificationData['shipment'] as Map + : null; + + // Both paths hand back the same outcome map, so everything below — + // the compliance stamp, the completed record, the next-stop list and the + // hand-off screen — runs once and unchanged for either. + final dynamic result = + (ServiceProfile.active.initiatesShipment && shipmentCapture != null) + ? await openScreen>( + context, + ShipmentReviewPage( + pickup: widget.pickup, + shipment: shipmentCapture, + trackingNo: () => dc.lastTrackingNo.value, + onConfirm: (method, amount) => _payAndCreateOrder( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + method: method, + amount: amount, + riderLat: riderLatStr, + riderLng: riderLngStr, + orderId: orderId, + ), + ), + swipeToGoBack: false, + ) + // Through the kit. The `shape: …Radius.circular(24)` here was dead + // weight *and* a contradiction: the route background is transparent, + // so the shape clipped nothing, and 24 is not the radius the glass + // actually draws. A number that has no effect is worse than a wrong + // one — the next person reads it as the system's value. + : await showMilerSheet( + context, + builder: (sheetContext) => _PickupBottomSheet( + pickup: widget.pickup, + parentState: parentState, + ), + ); if (!mounted) { _endNavigating(); @@ -1321,6 +1849,15 @@ class _PickupMapScreenState extends State<_PickupMapScreen> plannedKm: double.tryParse((widget.pickup['kms'] ?? '').toString()), ).toJson(); + // The clock this stop was promised by, out of the same prefs key the + // next stop overwrites. Kept beside the verdict because "on time" and + // "by when" are two different questions and the record could only + // answer the first. + final promised = dc.lastEtaDeadline.value; + if (promised != null) { + widget.pickup['etadeadline'] = promised.toIso8601String(); + } + // ── What was actually handled, and what was actually taken ── // // The weight the rider measured, the parcels he counted and the money @@ -1461,7 +1998,7 @@ class _LiveEtaHeader extends StatelessWidget { style: TextStyle( fontSize: known ? 30 : 20, height: 1.05, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -1.0, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1485,10 +2022,10 @@ class _LiveEtaHeader extends StatelessWidget { ), const SizedBox(width: 5), Text( - 'LIVE', + 'Active', style: TextStyle( fontSize: 10, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.8, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, @@ -1505,7 +2042,7 @@ class _LiveEtaHeader extends StatelessWidget { : 'Waiting for a GPS fix', style: TextStyle( fontSize: 13.5, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.1, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -1585,3 +2122,341 @@ double routeFlowFadeAlpha( final front = (1 + ramp) - f.clamp(0.0, 1.0) * (1 + 2 * ramp); return ((front - x) / ramp).clamp(0.0, 1.0); } + +/// ───────────────────────────────────────────────────────────────────────── +/// UPDATE STATUS — slide to open, then choose +/// +/// The delivery leg's one control, and the last thing the rider touches at a +/// door. Two states in one footprint: +/// +/// ``` +/// closed: [ Navigate ] [ ⟶ slide to update status ] +/// open: [ Delivered ] [ Skip ] [ Cancelled ] +/// ``` +/// +/// ── Why a slide and not three buttons ── +/// +/// The three outcomes are irreversible and one of them is *Cancelled*. A row of +/// tap targets on a phone in a jacket pocket, on a screen the rider is holding +/// while walking, is how a delivery gets cancelled by a knuckle. The slide is +/// the same gesture the app already reserves for consequential writes, and it +/// costs nothing at the one moment the rider is deliberately stopping to record +/// an outcome. +/// +/// It opens rather than commits, because *which* outcome is still a question — +/// a slide that committed would need three slides. +class _DeliveryStatusSlider extends StatefulWidget { + final VoidCallback onNavigate; + final void Function(DeliveryOutcome outcome, {String proofPath}) onChoose; + + /// The stop being closed — named on the proof page so the rider can see + /// which hand-over he is about to commit to. + final Map pickup; + + const _DeliveryStatusSlider({ + required this.onNavigate, + required this.onChoose, + required this.pickup, + }); + + @override + State<_DeliveryStatusSlider> createState() => _DeliveryStatusSliderState(); +} + +class _DeliveryStatusSliderState extends State<_DeliveryStatusSlider> { + /// How far along the track the thumb has been dragged, 0..1. + double _progress = 0; + + /// The fraction of the track that counts as "slid". + static const double _commitAt = 0.72; + + void _settle() { + if (_progress >= _commitAt) { + HapticFeedback.mediumImpact(); + _askOutcome(); + } else { + setState(() => _progress = 0); + } + } + + /// ── The question is a SHEET, not a panel in a sheet ── + /// + /// The outcomes used to render inline at the foot of the map's draggable + /// sheet — a white card inside frosted glass, with its own hand-rolled + /// close button, needing the host sheet animated taller just to be seen. + /// Three workarounds for one wrong container. Every other question this + /// app asks arrives on the kit's glass — handle, header, choice rows, one + /// entrance curve — and this, the most consequential question on the map, + /// was the one place still improvising. Dismissal is the handle and the + /// barrier, the same as everywhere; the slider resets either way. + Future _askOutcome() async { + setState(() => _progress = 1); + final outcome = await showMilerSheet( + context, + builder: (ctx) => MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const MilerSheetHeader( + title: 'How did this stop end?', + subtitle: 'This is recorded with the office straight away.', + ), + SizedBox(height: 16.h), + MilerButton( + label: DeliveryOutcome.delivered.label, + icon: DeliveryOutcome.delivered.icon, + color: ColorConstants.acceptGreen, + height: ButtonSizes.primary, + onPressed: () => Navigator.pop(ctx, DeliveryOutcome.delivered), + ), + Divider(height: 24.h, color: ColorConstants.borderSubtle), + _OutcomeRow( + outcome: DeliveryOutcome.skipped, + caption: 'Keep it on board and try again later', + onTap: () => Navigator.pop(ctx, DeliveryOutcome.skipped), + ), + _OutcomeRow( + outcome: DeliveryOutcome.cancelled, + caption: 'This order will not be delivered', + onTap: () => Navigator.pop(ctx, DeliveryOutcome.cancelled), + ), + SizedBox(height: 4.h), + ], + ), + ), + ); + if (!mounted) return; + setState(() => _progress = 0); + if (outcome == null) return; + + // ── A delivery is proved before it is recorded ── + // + // Delivered is the one outcome somebody may dispute weeks later, so it + // goes through the proof page: photo, preview, then **Mark as + // delivered** on that same screen. The other two outcomes are the + // rider reporting he could *not* hand over, and there is nothing to + // photograph. + // + // Three answers come back and they are not the same: a path (proof + // taken), `''` (completed deliberately without one — the camera failed + // or the customer objected), and null (he backed out, so nothing is + // recorded and the stop stays his). + if (outcome == DeliveryOutcome.delivered) { + final proof = await openScreen( + context, + DeliveryProofPage( + customer: _customerName(), + orderId: (widget.pickup['orderid'] ?? '').toString(), + ), + ); + if (!mounted || proof == null) return; + widget.onChoose(outcome, proofPath: proof); + return; + } + + widget.onChoose(outcome); + } + + /// The name the proof page shows, so the rider can see which hand-over he + /// is closing before he commits to it. + String _customerName() { + for (final k in const [ + 'dropcustomer', + 'pickupcustomer', + 'PickupCustomer', + 'name', + ]) { + final v = (widget.pickup[k] ?? '').toString().trim(); + if (v.isNotEmpty) return v; + } + return 'Customer'; + } + + @override + Widget build(BuildContext context) => _closed(context); + + Widget _closed(BuildContext context) { + return SizedBox( + height: ButtonSizes.primary, + width: double.infinity, + child: Row( + children: [ + Expanded( + flex: 4, + child: MilerButton( + label: 'Navigate', + icon: LucideIcons.navigation, + color: ColorConstants.primary, + onPressed: widget.onNavigate, + ), + ), + const SizedBox(width: 12), + Expanded(flex: 6, child: _track(context)), + ], + ), + ); + } + + Widget _track(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final double travel = (constraints.maxWidth - _thumb).clamp( + 1.0, + double.infinity, + ); + // ── A drag-only control is a control some riders cannot use ── + // + // The slide exists to make finishing a stop deliberate, and that is + // right: it is the gesture that opens the outcome question. But a bare + // `GestureDetector` announces nothing and offers no path at all to + // anyone driving the screen with TalkBack, or to a rider whose gloves + // will not register a drag on a cold morning. + // + // The deliberateness is preserved exactly, because the slide never + // committed anything: it opens `How did this stop end?`, and the rider + // still has to choose there. So the accessible action opens the same + // question — one extra confirmed step either way, and nobody is locked + // out of finishing their own delivery. + return Semantics( + button: true, + label: 'Finish this stop', + hint: 'Slide, or double tap, to choose how the stop ended', + onTap: _askOutcome, + excludeSemantics: true, + child: GestureDetector( + onHorizontalDragUpdate: (d) => setState( + () => _progress = (_progress + d.delta.dx / travel).clamp(0.0, 1.0), + ), + onHorizontalDragEnd: (_) => _settle(), + onHorizontalDragCancel: _settle, + child: Container( + decoration: BoxDecoration( + color: ColorConstants.acceptGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // The label fades as the thumb covers it, rather than sitting + // under the thumb being half-read. + Opacity( + opacity: (1 - _progress * 1.6).clamp(0.0, 1.0), + child: Padding( + padding: EdgeInsets.only(left: _thumb * 0.6), + child: Text( + 'Update status', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only(left: travel * _progress), + child: Container( + width: _thumb, + height: _thumb, + decoration: BoxDecoration( + color: ColorConstants.acceptGreen, + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: Icon( + LucideIcons.chevronRight, + color: Colors.white, + size: 26.sp, + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + double get _thumb => ButtonSizes.primary - 8; +} + +/// One exception, as a row: glyph in a tinted disc, what it is, what it means. +class _OutcomeRow extends StatelessWidget { + final DeliveryOutcome outcome; + final String caption; + final VoidCallback onTap; + + const _OutcomeRow({ + required this.outcome, + required this.caption, + required this.onTap, + }); + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 9.h, horizontal: 4.w), + child: Row( + children: [ + Container( + width: 34.w, + height: 34.w, + decoration: BoxDecoration( + color: outcome.colour.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon(outcome.icon, size: 18.sp, color: outcome.colour), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + outcome.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 1.h), + Text( + caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + height: 1.3, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + Icon( + LucideIcons.chevronRight, + size: 20.sp, + color: ColorConstants.borderStrong, + ), + ], + ), + ), + ); +} diff --git a/lib/views/Dashboard/pickups/map_btn.dart b/lib/views/Dashboard/pickups/map_btn.dart index fb4888a..4023551 100644 --- a/lib/views/Dashboard/pickups/map_btn.dart +++ b/lib/views/Dashboard/pickups/map_btn.dart @@ -26,13 +26,16 @@ class MapViewRow extends StatelessWidget { swipeToGoBack: false, ); }, + // White, because this sits on the brand app bar — see [MilerAppBar]. + // Maroon-on-maroon made the control invisible the moment the bar stopped + // being a frosted wash over white. child: Image.asset( 'assets/images/customermap.png', - color: ColorConstants.primary, + color: Colors.white, height: 32, width: 32, errorBuilder: (c, e, s) => - Icon(Icons.map, size: 32, color: ColorConstants.primary), + const Icon(LucideIcons.map, size: 32, color: Colors.white), ), ); } diff --git a/lib/views/Dashboard/pickups/multi_map.dart b/lib/views/Dashboard/pickups/multi_map.dart index 4c3b453..3f092c9 100644 --- a/lib/views/Dashboard/pickups/multi_map.dart +++ b/lib/views/Dashboard/pickups/multi_map.dart @@ -491,15 +491,24 @@ class _MultiCustomerMapScreenState extends State } void _showCustomerDetailsSheet(Map pickup, int stepNumber) { - showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - isDismissible: true, - enableDrag: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + // ── Through the kit ── + // + // The furthest-drifted sheet in the app, and every difference was a + // decision re-made rather than inherited: + // + // • its own `RoundedRectangleBorder(20)` — a radius nothing else uses; + // • **no glass at all**, so it arrived opaque white over a live map while + // every other sheet is frosted; + // • `SafeArea(bottom: true)` **and** `Padding(bottom: viewInsets.bottom)` + // — the two summed, which is the double-inset bug written out in full: + // with the keyboard up the content floats a navigation bar's height + // above it; + // • a 24 gutter against the app's 20; + // • a 22sp/w600 title in **brand red**, where the system's header is + // 18/w700 slate — the accent belongs on the badge, not on the sentence; + // • and a close ✕, which the handle and the barrier already do. + showMilerSheet( + context, builder: (BuildContext context) { final customerName = (pickup['pickupcustomer'] ?? 'Customer') .toString(); @@ -511,267 +520,226 @@ class _MultiCustomerMapScreenState extends State .trim(); final isSkipped = status == 'skipped'; - return SafeArea( - top: false, - left: false, - right: false, - bottom: true, - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + return MilerSheetScaffold( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MilerSheetHeader( + title: 'Customer details', + icon: LucideIcons.mapPinned, + ), + SizedBox(height: 4.h), + + // Step number and status + Row( children: [ - // Header with title and close icon - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - 'Customer Details', - style: TextStyle( - fontSize: 22.sp, - fontWeight: FontWeight.bold, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.primary, - ), - ), - ), - InkWell( - onTap: () => Navigator.of(context).pop(), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: ColorConstants.dividerColor, - shape: BoxShape.circle, - ), - // A close affordance, not a destructive action. - // This was a 34px red `Icons.cancel` — the loudest - // thing in the sheet, competing with the call button - // and reading as "delete this stop". - child: Icon( - Icons.close_rounded, - size: 20.sp, - color: ColorConstants.slateText, - ), - ), - ), - ], - ), - - const Divider(thickness: 1.5), - - // Step number and status - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: isSkipped - ? ColorConstants.warning - : ColorConstants.primary, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Text( - 'Step $stepNumber', - style: TextStyle( - fontFamily: FontConstants.fontFamily, - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14.sp, - ), - ), - ), - if (isSkipped) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: ColorConstants.warning, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Text( - 'SKIPPED', - style: TextStyle( - fontFamily: FontConstants.fontFamily, - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12.sp, - ), - ), - ), - ], - ], - ), - const SizedBox(height: 16), - - // Customer name - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.person, - size: 20, - color: ColorConstants.secondaryText, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Customer Name', - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 16.sp, - color: ColorConstants.slateText, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - customerName, - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - - // Pickup address - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.location_on, - size: 20, - color: ColorConstants.secondaryText, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Recipient Address', - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 16.sp, - color: ColorConstants.slateText, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - address, - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - - // Phone number - if (phone.isNotEmpty) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.phone, - size: 20, - color: ColorConstants.secondaryText, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Contact Number', - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 16.sp, - color: ColorConstants.slateText, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - phone, - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - ), - ), - ], - ), - ), - ], + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, ), - const SizedBox(height: 20), - - // Booking ID - Text( - 'Booking ID: $orderId', - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 16.sp, - color: ColorConstants.slateText, - fontStyle: FontStyle.italic, + decoration: BoxDecoration( + color: isSkipped + ? ColorConstants.warning + : ColorConstants.primary, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Text( + 'Step $stepNumber', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14.sp, + ), ), ), - const SizedBox(height: 20), - - // Call button - MilerButton( - label: 'Call', - icon: Icons.phone, - color: ColorConstants.acceptGreen, - onPressed: () async { - // Close bottom sheet first - Navigator.of(context).pop(); - - // Small delay to ensure bottom sheet is closed - await Future.delayed(const Duration(milliseconds: 300)); - - // Then make the call - final bool success = await launchPhoneDialer(phone); - if (!success && context.mounted) { - AppFeedback.error(context, 'Could not open the dialer'); - } - }, - ), - const SizedBox(height: 8), + if (isSkipped) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: ColorConstants.warning, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Text( + 'SKIPPED', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12.sp, + ), + ), + ), + ], ], ), - ), + const SizedBox(height: 16), + + // Customer name + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.user, + size: 20, + color: ColorConstants.secondaryText, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Customer Name', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 16.sp, + color: ColorConstants.slateText, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + customerName, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 18.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Pickup address + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.mapPin, + size: 20, + color: ColorConstants.secondaryText, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recipient Address', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 16.sp, + color: ColorConstants.slateText, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + address, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 18.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Phone number + if (phone.isNotEmpty) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.phone, + size: 20, + color: ColorConstants.secondaryText, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contact Number', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 16.sp, + color: ColorConstants.slateText, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + phone, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 18.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Booking ID + Text( + 'Booking ID: $orderId', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 16.sp, + color: ColorConstants.slateText, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 20), + + // Call button + MilerButton( + label: 'Call', + icon: LucideIcons.phone, + color: ColorConstants.acceptGreen, + onPressed: () async { + // Close bottom sheet first + Navigator.of(context).pop(); + + // Small delay to ensure bottom sheet is closed + await Future.delayed(const Duration(milliseconds: 300)); + + // Then make the call + final bool success = await launchPhoneDialer(phone); + if (!success && context.mounted) { + AppFeedback.error(context, 'Could not open the dialer'); + } + }, + ), + const SizedBox(height: 8), + ], ), ), ); @@ -823,35 +791,17 @@ class _MultiCustomerMapScreenState extends State // Inverted, and rebuilt as an ordinary AppBar: white surface, brand red on // the title where it costs nothing, a real `leading` slot, and the same // hairline the rest of the app uses to close a bar. - appBar: AppBar( - // The app-wide frosted brand wash — see [milerGlassSurface]. - backgroundColor: Colors.transparent, - flexibleSpace: milerGlassSurface(), - surfaceTintColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - titleSpacing: 0, - leading: IconButton( - tooltip: 'Back', - icon: Icon( - Icons.arrow_back_rounded, - color: ColorConstants.slateText, - size: 22.sp, - ), - onPressed: () => Navigator.pop(context), - ), - title: Text( - 'Route map', - style: TextStyle( - fontFamily: FontConstants.fontFamily, - fontSize: 19.sp, - color: ColorConstants.primary, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - ), - ), - centerTitle: false, - ), + // ── The sixth app-bar implementation, folded into the one ── + // + // This was a hand-rolled `AppBar`: a frosted wash, a maroon 19sp title + // and its own back button — so the map page's heading was a different + // colour and a different size from every other pushed screen. The audit + // note on [milerPageBar] says that bar is the only one a pushed screen + // gets; this one had simply never been converted. + // + // Now it inherits the brand ground, the white title and the shared back + // control along with everything else, and cannot drift again. + appBar: milerPageBar('Route map', onBack: () => Navigator.pop(context)), body: _isLoading ? const Center(child: CircularProgressIndicator()) : AfterEntrance( diff --git a/lib/views/Dashboard/pickups/payment_screen.dart b/lib/views/Dashboard/pickups/payment_screen.dart index 2422747..723d46e 100644 --- a/lib/views/Dashboard/pickups/payment_screen.dart +++ b/lib/views/Dashboard/pickups/payment_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; @@ -161,7 +162,7 @@ class _PaymentScreenState extends State ], ), child: Icon( - Icons.check_rounded, + LucideIcons.check, color: Colors.white, size: 52.sp, ), @@ -173,7 +174,7 @@ class _PaymentScreenState extends State 'Payment Collected Successfully', style: TextStyle( fontSize: 26.sp, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -230,7 +231,7 @@ class _PaymentScreenState extends State ), ), child: Icon( - Icons.receipt_long_rounded, + LucideIcons.receiptText, color: ColorConstants.primary, size: 18.sp, ), @@ -244,7 +245,7 @@ class _PaymentScreenState extends State 'Payment Receipt', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -355,7 +356,7 @@ class _PaymentScreenState extends State value, style: TextStyle( fontSize: 14.sp, - fontWeight: valueBold ? FontWeight.w700 : FontWeight.w600, + fontWeight: valueBold ? FontWeight.w600 : FontWeight.w500, color: valueColor ?? ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -406,7 +407,7 @@ class _PaymentScreenState extends State shape: BoxShape.circle, ), child: Icon( - Icons.arrow_back_ios_new_rounded, + LucideIcons.chevronLeft, color: Colors.white, size: 16.sp, ), @@ -417,7 +418,7 @@ class _PaymentScreenState extends State 'Payment Collection', style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, letterSpacing: 0.5, @@ -451,7 +452,7 @@ class _PaymentScreenState extends State '₹${_totalAmount.toStringAsFixed(0)}', style: TextStyle( fontSize: 36.sp, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, letterSpacing: -1, @@ -482,7 +483,7 @@ class _PaymentScreenState extends State '#$_orderId', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -566,7 +567,7 @@ class _PaymentScreenState extends State SizedBox(height: 10.h), _buildPaymentOption( - Icons.money_rounded, + LucideIcons.banknote, 'Cash', 'Pay with cash on collection', 'cash', @@ -574,7 +575,7 @@ class _PaymentScreenState extends State ), SizedBox(height: 10.h), _buildPaymentOption( - Icons.qr_code_scanner_rounded, + LucideIcons.scanLine, 'QR / UPI', 'Scan QR code to pay', 'upi', @@ -582,7 +583,7 @@ class _PaymentScreenState extends State ), SizedBox(height: 10.h), _buildPaymentOption( - Icons.credit_card_rounded, + LucideIcons.creditCard, 'Card', 'Swipe / tap card terminal', 'card', @@ -597,7 +598,9 @@ class _PaymentScreenState extends State height: ButtonSizes.primary, decoration: BoxDecoration( color: ColorConstants.primary, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), ), child: const Center( child: SizedBox( @@ -611,7 +614,9 @@ class _PaymentScreenState extends State ), ) : ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), child: Container( color: ColorConstants.primary, child: SliderButton( @@ -637,7 +642,7 @@ class _PaymentScreenState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -649,7 +654,7 @@ class _PaymentScreenState extends State shape: BoxShape.circle, ), child: Icon( - Icons.payments_rounded, + LucideIcons.banknote, size: 22.sp, color: ColorConstants.primary, ), @@ -685,7 +690,7 @@ class _PaymentScreenState extends State label, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, letterSpacing: 0.5, @@ -754,7 +759,7 @@ class _PaymentScreenState extends State title, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -784,7 +789,7 @@ class _PaymentScreenState extends State ), ), child: isSelected - ? Icon(Icons.check_rounded, color: Colors.white, size: 14.sp) + ? Icon(LucideIcons.check, color: Colors.white, size: 14.sp) : null, ), ], @@ -809,7 +814,7 @@ class _PaymentScreenState extends State style: TextStyle( fontSize: isTotal ? 15.sp : 13.sp, color: ColorConstants.onSurfaceVariant, - fontWeight: isBold ? FontWeight.w600 : FontWeight.normal, + fontWeight: isBold ? FontWeight.w500 : FontWeight.normal, fontFamily: FontConstants.fontFamily, ), ), @@ -818,8 +823,8 @@ class _PaymentScreenState extends State style: TextStyle( fontSize: isTotal ? 17.sp : 14.sp, fontWeight: isTotal - ? FontWeight.w800 - : (isBold ? FontWeight.w700 : FontWeight.w500), + ? FontWeight.w700 + : (isBold ? FontWeight.w600 : FontWeight.w500), color: isTotal ? ColorConstants.primary : ColorConstants.onSurface, diff --git a/lib/views/Dashboard/pickups/pickup_verify.dart b/lib/views/Dashboard/pickups/pickup_verify.dart index 8e698f4..0a4233a 100644 --- a/lib/views/Dashboard/pickups/pickup_verify.dart +++ b/lib/views/Dashboard/pickups/pickup_verify.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:image_picker/image_picker.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; @@ -175,7 +176,7 @@ class _PickupVerificationPageState extends State SizedBox(height: 20.h), _buildSectionHeader( - icon: Icons.camera_alt_rounded, + icon: LucideIcons.camera, title: _isDelivery ? 'Delivery Photo' : 'Parcel Photo', badge: 'Required', isComplete: _photoVerified, @@ -189,7 +190,7 @@ class _PickupVerificationPageState extends State // a first-mile pickup, so it was pure friction on every stop. if (!_isDelivery) ...[ _buildSectionHeader( - icon: Icons.inventory_2_rounded, + icon: LucideIcons.package, title: 'Parcel Details (Optional)', isComplete: _weightController.text.isNotEmpty, ), @@ -199,7 +200,7 @@ class _PickupVerificationPageState extends State ], _buildSectionHeader( - icon: Icons.lock_outline_rounded, + icon: LucideIcons.lock, title: _isDelivery ? 'Delivery OTP' : 'Pickup Code', badge: 'Required', isComplete: _otpController.text.isNotEmpty, @@ -219,7 +220,7 @@ class _PickupVerificationPageState extends State ).copyWith( counterText: '', prefixIcon: Icon( - Icons.lock_outline_rounded, + LucideIcons.lock, color: ColorConstants.secondaryText, size: 20.sp, ), @@ -255,7 +256,7 @@ class _PickupVerificationPageState extends State leading: IconButton( tooltip: 'Back', icon: Icon( - Icons.arrow_back_ios_new_rounded, + LucideIcons.chevronLeft, color: ColorConstants.onSurface, size: 20.sp, ), @@ -271,7 +272,7 @@ class _PickupVerificationPageState extends State style: TextStyle( fontSize: 21.sp, color: ColorConstants.onSurface, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -337,7 +338,7 @@ class _PickupVerificationPageState extends State ), ), child: Icon( - done ? Icons.check_rounded : Icons.circle_outlined, + done ? LucideIcons.check : LucideIcons.circle, color: done ? Colors.white : ColorConstants.secondaryText, size: 16.sp, ), @@ -347,7 +348,7 @@ class _PickupVerificationPageState extends State label, style: TextStyle( fontSize: 10.sp, - fontWeight: done ? FontWeight.w700 : FontWeight.w500, + fontWeight: done ? FontWeight.w600 : FontWeight.w500, color: done ? _successGreen : ColorConstants.onSurfaceVariant, @@ -411,7 +412,7 @@ class _PickupVerificationPageState extends State style: TextStyle( color: Colors.white, fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -426,7 +427,7 @@ class _PickupVerificationPageState extends State _customerName, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -452,7 +453,7 @@ class _PickupVerificationPageState extends State ), SizedBox(height: 12.h), _buildLocationRow( - Icons.location_on_rounded, + LucideIcons.mapPin, _pickupAddress, _isDelivery ? 'DELIVERY' : 'PICKUP', ColorConstants.warning.withValues(alpha: 0.40), @@ -488,7 +489,7 @@ class _PickupVerificationPageState extends State label, style: TextStyle( fontSize: 9.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: accentColor, letterSpacing: 0.8, fontFamily: FontConstants.fontFamily, @@ -529,7 +530,7 @@ class _PickupVerificationPageState extends State borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Icon( - isComplete ? Icons.check_circle_rounded : icon, + isComplete ? LucideIcons.circleCheck : icon, size: 16.sp, color: isComplete ? _successGreen : ColorConstants.onSurfaceVariant, ), @@ -540,7 +541,7 @@ class _PickupVerificationPageState extends State title, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -557,7 +558,7 @@ class _PickupVerificationPageState extends State badge, style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: _primaryRed, fontFamily: FontConstants.fontFamily, ), @@ -622,7 +623,7 @@ class _PickupVerificationPageState extends State child: Row( children: [ Icon( - Icons.check_circle_rounded, + LucideIcons.circleCheck, color: Colors.white, size: 16.sp, ), @@ -649,7 +650,7 @@ class _PickupVerificationPageState extends State shape: BoxShape.circle, ), child: Icon( - Icons.check_rounded, + LucideIcons.check, color: Colors.white, size: 14.sp, ), @@ -668,7 +669,7 @@ class _PickupVerificationPageState extends State shape: BoxShape.circle, ), child: Icon( - Icons.camera_alt_rounded, + LucideIcons.camera, color: ColorConstants.secondaryText, size: 36.sp, ), @@ -678,7 +679,7 @@ class _PickupVerificationPageState extends State 'Tap to take parcel photo', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), @@ -737,7 +738,7 @@ class _PickupVerificationPageState extends State label, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -749,7 +750,7 @@ class _PickupVerificationPageState extends State style: TextStyle( fontSize: 13.sp, color: _primaryRed, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -790,7 +791,7 @@ class _PickupVerificationPageState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.lock_outline_rounded, + LucideIcons.lock, color: ColorConstants.secondaryText, size: 18.sp, ), @@ -805,7 +806,7 @@ class _PickupVerificationPageState extends State : 'Complete all fields to continue', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -843,7 +844,7 @@ class _PickupVerificationPageState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -855,7 +856,7 @@ class _PickupVerificationPageState extends State shape: BoxShape.circle, ), child: Icon( - Icons.check_rounded, + LucideIcons.check, size: 22.sp, color: ColorConstants.primary, ), diff --git a/lib/views/Dashboard/pickups/pickups.dart b/lib/views/Dashboard/pickups/pickups.dart index dfb255a..cacd994 100644 --- a/lib/views/Dashboard/pickups/pickups.dart +++ b/lib/views/Dashboard/pickups/pickups.dart @@ -1,19 +1,31 @@ // ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast library; +// ── Dead visual effects, removed ── +// +// This file imported `confetti`, `scratcher`, `circular_countdown_timer`, +// `slider_button_lite` and `lottie` alongside the delivery widgets — the +// remains of a celebratory completion flow. Every one of them had **zero** +// references left in the file; they were kept alive only by their import +// lines, which is how a scratch-card and a confetti burst stay one wiring +// mistake away from a field-service queue. +// +// The packages stay in `pubspec.yaml` because other screens use some of them; +// what is removed here is this file's claim on them. import 'dart:async'; import 'dart:convert'; -import 'dart:ui' as ui; -import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/foundation.dart' show kDebugMode, setEquals; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get/get.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:miler/views/helpers/widgets/miler_map.dart'; -import 'package:lottie/lottie.dart' hide Marker; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/helpers/poll_policy.dart'; @@ -26,34 +38,47 @@ import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; import 'package:miler/views/Dashboard/home/trip_progress_rail.dart'; import 'package:miler/views/Dashboard/home/homepage_banner.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; -import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart'; -import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:miler/providers/pickup/pickup_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/controllers/pickups_controller.dart'; import 'package:miler/controllers/duty_controller.dart'; -import 'package:miler/models/stop_status.dart'; +import 'package:miler/Models/stop_status.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:miler/widget/Bottom_page.dart'; import 'package:circular_countdown_timer/circular_countdown_timer.dart'; import 'dart:math' as math; -import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; import 'package:miler/views/Dashboard/pickups/collect_payment.dart'; import 'package:miler/controllers/riderlog.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; -import 'package:miler/views/helpers/constants/apiconstants.dart'; -import 'dart:io' show Platform; import 'package:miler/data/api_config.dart'; import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/consignment_state.dart'; +import 'package:miler/data/proof_store.dart'; +import 'package:miler/views/Dashboard/pickups/delivery_proof_page.dart'; +import 'package:miler/data/mutation_guard.dart'; import 'package:miler/data/stop_compliance.dart'; import 'package:flutter/services.dart'; -import 'package:floating/floating.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:http/http.dart' as http; import 'package:scratcher/scratcher.dart'; import 'package:confetti/confetti.dart'; -import 'package:miler/views/helpers/constants/miler_type.dart'; +import 'package:miler/data/service_profile.dart'; +// The two pages the logistics arrival opens: proof of what is being collected, +// then the shipment desk that establishes where it is going and what it costs. +import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; +import 'package:miler/views/Dashboard/pickups/shipment_capture.dart'; +import 'package:miler/views/Dashboard/pickups/shipment_review.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/order_events.dart'; +import 'package:miler/data/route_order.dart'; +import 'package:miler/data/stop_area.dart'; +import 'package:miler/data/stop_contact.dart'; +import 'package:miler/data/work_domain.dart'; +import 'package:miler/data/work_repository.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/assignment_lookup.dart'; +import 'package:miler/utils/external_navigation.dart'; part 'card.dart'; part 'map.dart'; @@ -63,6 +88,7 @@ part 'map_btn.dart'; part 'multi_map.dart'; part 'done.dart'; part 'skip_sheet.dart'; +part 'delivery_actions.dart'; // `part '../Cart/cartpage.dart';` — REMOVED FROM THE BUILD. // // 1,070 lines of a Cart screen that nothing in the app ever instantiated: @@ -160,6 +186,30 @@ class MyPickups extends StatefulWidget { forgetSkip(pickupId: (stop['pickupid'] ?? '').toString(), orderId: orderId); + // ── A delivery park is taken back locally, because that is all it was ── + // + // The accept below is the right call for a **pickup** the rider walked + // away from: the booking really did go back to undecided, and resuming is + // a genuine re-acceptance. + // + // It is the wrong call for a stop skipped at a customer's door. That + // booking is `Converted_To_Consignment` — collected, converted, past the + // accept rung entirely — and posting an accept against it is the wrong + // object at the wrong point in its life. The consignment was never closed + // by the skip (see `closeDelivery`); the only thing standing between the + // rider and a second attempt is the local park, so removing it *is* the + // resume. No server call, because there is no server state to undo. + if (_isDeliveryLegStop(stop)) { + await removeSkippedBookings([orderId]); + await clearOrderEvents(orderId); + stop['orderstatus'] = 'outfordelivery'; + if (Get.isRegistered()) { + Get.find().triggerRefresh(); + } + debugPrint('[RESUME] $orderId un-parked on the delivery leg'); + return true; + } + final dc = Get.isRegistered() ? Get.find() : Get.put(PickupsController(), permanent: true); @@ -195,12 +245,33 @@ class MyPickups extends StatefulWidget { ..['orderstatus'] = 'accepted'; await removeSkippedBookings([orderId]); await addAcceptedBookings([resumed]); + // A resumed skip is a genuinely new attempt at the same door, so the first + // attempt's clocks describe a visit that has been superseded. Same argument + // `addCompletedBookings` makes when it clears the keys it has just read. + await clearOrderEvents((resumed['orderid'] ?? '').toString()); stop['orderstatus'] = 'accepted'; dc.triggerRefresh(); return true; } + /// True when this stop's remaining work is a **delivery**, not a collection. + /// + /// Asked of the row rather than of the screen, because resume is offered + /// from Activity, where there is no queue to consult: a stop that carries a + /// consignment, or whose status is a delivery rung, is past the collection + /// half of its life whatever tab it is being looked at from. + static bool _isDeliveryLegStop(Map stop) { + final consignment = (stop['consignmentid'] ?? '').toString().trim(); + if (consignment.isNotEmpty && consignment != '0') return true; + if (consignmentStateFromRaw(stop['consignmentstatus']) != + ConsignmentState.unknown) { + return true; + } + final status = stopStatusFromRaw(stop['orderstatus']); + return status.isDeliveryLeg || status.isPicked; + } + // ✅ Public static method to navigate to pickup map screen from outside (e.g., home page) static Future navigateToPickupMap( BuildContext context, @@ -354,6 +425,30 @@ class _MyPickupsState extends State /// Refreshed with every fetch. See [_railStops] for why the rail needs them. List> _completedToday = >[]; + // ══════════════════════════════════════════════════════════════════════ + // THE MILK RUN'S ROUND + // + // This tab is where a milk-run rider's load lands and where his round + // starts. Everything below is read on every fetch rather than held from the + // last action, so the screen is right after a restart mid-shift too. + // ══════════════════════════════════════════════════════════════════════ + + /// Every stop the hub assigned today, unfiltered. + /// + /// The gate needs the whole day, not this tab's list: "is the load complete?" + /// is a question about the orders still sitting on **Home** waiting to be + /// collected, and those are precisely the ones this screen filters out. + List> _allDayStops = >[]; + + Set _acceptedIds = {}; + Set _collectedIds = {}; + Set _outForDeliveryIds = {}; + Set _notLoadedIds = {}; + Set _rejectedIds = {}; + + /// Whether this rider's line delivers what it collects. + bool get _isMilkRun => ServiceProfile.active.deliversToCustomer; + /// What the rail draws: the whole run, finished stops included. /// /// ── Why this is not [_visibleStops] ── @@ -387,23 +482,17 @@ class _MyPickupsState extends State /// order — what drives the red→green rail at the top of Bookings. /// /// Exactly one stop is ever [StopProgress.current]: whichever the rider is - /// physically on (active/arrived), or failing that the first stop still to - /// be done. Skipped stops keep their own state so they read as unfinished - /// business rather than either done or next. + /// physically on, or failing that the first stop still to be done. Skipped + /// stops keep their own state so they read as unfinished business rather + /// than either done or next. + /// + /// The per-stop mapping is [stopProgressFor], which is pure and lives in + /// `trip.dart` so it can be tested — this State cannot be pumped. Read its + /// doc for why "physically on" has to be asked in both legs' vocabulary. List _tripProgress() { - final states = []; - for (final stop in _railStops) { - final status = stopStatusOf(stop); - if (status.isFinishedPickup) { - states.add(StopProgress.done); - } else if (status.isSkipped) { - states.add(StopProgress.skipped); - } else if (status.isActive || status == StopStatus.arrived) { - states.add(StopProgress.current); - } else { - states.add(StopProgress.pending); - } - } + final states = [ + for (final stop in _railStops) stopProgressFor(stopStatusOf(stop)), + ]; // No stop is live: promote the first pending one so the rail always shows // the rider where he is in the sequence. @@ -533,6 +622,124 @@ class _MyPickupsState extends State for (final trip in _bookingTrips) ...trip.stops, ]; + /// Which bag each order is in, as recorded at the counter. + /// + /// Read once per refresh rather than per card: it is one preferences hit, and + /// a `FutureBuilder` per row would blink a bagless card in on every rebuild. + Map _bagLabels = const {}; + + Future _loadBagLabels() async { + final labels = await getBagLabels(); + if (!mounted) return; + setState(() => _bagLabels = labels); + } + + /// ── The queue, in the order it will be worked ── + /// + /// A delivery list where every card looks the same makes the rider decide + /// which one is next — a decision the app already knows the answer to. So the + /// list is cut into three, and only the first section is live: + /// + /// **NOW** — the one stop he is working. The only enabled card. + /// **UP NEXT** — the one after it, so he can see where he is heading. + /// **LATER** — the rest, in order. + /// + /// Skipped stops sit above NOW, under their own heading: they are earlier in + /// route order and still resumable, and burying them under LATER would hide + /// work he has already been to once. + /// + /// This adds no new sequencing rule. Which card is live was already decided + /// here — the first non-skipped stop, and nothing while another is running — + /// and the sections simply say out loud what that logic already meant. + List<_QueueRow> _queueRows() { + // ── `isWorkComplete`, not `isPicked` ── + // + // This dropped every stop whose raw rung was `picked`. On a parcel route + // that is correct by accident — collection IS the end of the job 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. + // + // So collecting a crate at the kitchen made the card vanish off this screen + // while the rail above it — which correctly reads the profile-aware + // predicate — kept drawing the stop as a node. That is the exact symptom: + // a rail with a stop on it and no card underneath. + // + // [StopStatusX.isWorkComplete] is the one that asks "should this still be + // worked today?", and its own doc says to read it here. Nothing else on + // this screen was using the raw rung. + final workable = [ + for (final s in _visibleStops) + if (!stopStatusOf(s).isCancelled && !stopStatusOf(s).isWorkComplete) s, + ]; + if (workable.isEmpty) return const []; + + final hasActive = _hasActivePickup(); + final liveIndex = workable.indexWhere((s) => !stopStatusOf(s).isSkipped); + + final rows = <_QueueRow>[]; + var skippedSeen = false; + var laterSeen = false; + + for (var i = 0; i < workable.length; i++) { + final stop = workable[i]; + final skipped = stopStatusOf(stop).isSkipped; + final before = i < (liveIndex < 0 ? workable.length : liveIndex); + + QueueDensity density; + if (skipped) { + density = QueueDensity.skipped; + } else if (i == liveIndex && !hasActive) { + density = QueueDensity.now; + } else if (i == liveIndex || i == liveIndex + 1) { + // While another stop is already running there is no live card at all — + // the one he is working is on its own screen — so the head of the queue + // reads as "next" rather than pretending to be actionable. + density = QueueDensity.next; + } else { + density = QueueDensity.later; + } + + if (skipped && !skippedSeen && before) { + skippedSeen = true; + rows.add(const _QueueRow.heading('SKIPPED')); + } else if (density == QueueDensity.now) { + rows.add(const _QueueRow.heading('NOW')); + } else if (!skipped && i == liveIndex + (hasActive ? 0 : 1)) { + rows.add(const _QueueRow.heading('UP NEXT')); + } else if (density == QueueDensity.later && !laterSeen) { + laterSeen = true; + rows.add(_QueueRow.heading('LATER', count: workable.length - i)); + } + + rows.add( + _QueueRow.stop( + stop: stop, + density: density, + displayStep: _getDisplayStepNumber( + _visibleStops, + _visibleStops.indexOf(stop), + ), + bag: _bagLabels[(stop['orderid'] ?? '').toString()] ?? '', + reason: _skippedOrdersCache[_getOrderKey(stop)] ?? '', + ), + ); + } + return rows; + } + + /// Puts a skipped stop back in the queue. + /// + /// The work itself is [MyPickups.resumeSkippedStop] — a static, because three + /// screens have owned this control in turn and the logic moved with it every + /// time. This is only the screen-local half: refresh the list so the row + /// leaves SKIPPED and rejoins the queue in route order. + Future _resumeSkipped(Map stop) async { + final ok = await MyPickups.resumeSkippedStop(stop); + if (!mounted || !ok) return; + AppFeedback.successGlobal('Back in your queue'); + await _fetchPicked(force: true); + } + /// Shown when the rider has accepted work but none of it is outstanding. /// /// With the trip tabs gone this is a much simpler state than it was: there is @@ -559,7 +766,7 @@ class _MyPickupsState extends State border: Border.all(color: ColorConstants.borderStrong), ), child: Icon( - Icons.check_circle_outline_rounded, + LucideIcons.circleCheck, size: 28.sp, color: ColorConstants.acceptGreen, ), @@ -571,7 +778,7 @@ class _MyPickupsState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -584,7 +791,7 @@ class _MyPickupsState extends State style: TextStyle( fontSize: 12.5.sp, height: 1.45, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -608,7 +815,7 @@ class _MyPickupsState extends State style: TextStyle( fontSize: 10.sp, height: 1.5, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -642,6 +849,9 @@ class _MyPickupsState extends State i, hubLat: _currentLocation?.latitude, hubLng: _currentLocation?.longitude, + // Door to door on a round — the walk over the pickup pairs was a + // tour of kitchens already behind him. See [Trip.travelTimeToStop]. + deliveryLeg: _isMilkRun, ); } @@ -714,32 +924,53 @@ class _MyPickupsState extends State ); } + /// Which rule ordered the queue the rider is looking at. + /// + /// Shown on the list — see [_orderNote] — because a queue ordered by the + /// app's own guess and one ordered by the hub's solved route look identical, + /// and only one of them is a plan anyone else knows about. + RouteOrderSource _orderSource = RouteOrderSource.backendOrder; + + /// Puts the delivery queue in the order it is to be worked. + /// + /// ── What this used to do, and why it was wrong ── + /// + /// It split the list in two: stops carrying a `step` were sorted by it, and + /// everything else was sorted **by straight-line distance from the rider's + /// current GPS fix** and appended. Two failures in one. + /// + /// The first is that it produced a list that was neither order — half the + /// hub's route, half the app's. The second is that in practice it was + /// *always* the second half: `step` reached the app only through + /// `GET /miler/assignments`, which is deliberately the active queue + /// (`Assigned`/`Accepted`), and a booking leaves those states the instant it + /// is collected. Every delivery therefore arrived here unsequenced and was + /// re-planned nearest-first, while the hub believed its route was being + /// followed and the rider had no way to know it was not. + /// + /// The sequence now comes down on the booking row itself, and the rule is + /// [RouteOrder]'s — the same one the pickup leg uses. Distance is the last + /// resort and never mixes with an assigned order. List> _sortOrders(List> bookings) { if (bookings.isEmpty) return bookings; - // Use preserved step numbers for sorting to maintain original Booking - final ordersWithStep = bookings - .where((o) => _getPreservedOrCurrentStep(o) > 0) - .toList(); - final ordersWithoutStep = bookings - .where((o) => _getPreservedOrCurrentStep(o) == 0) - .toList(); - - ordersWithStep.sort( - (a, b) => _getPreservedOrCurrentStep( - a, - ).compareTo(_getPreservedOrCurrentStep(b)), + final (ordered, source) = RouteOrder.sort( + bookings, + bookedTimeOf: (o) => DateTime.tryParse( + (o['expected_pickup_time'] ?? o['expectedpickuptime'] ?? o['eta'] ?? '') + .toString(), + ), + // Offered, not preferred: [RouteOrder.sort] reaches for it only when no + // stop in the set carries a sequence and none carries a booked time. + distanceTo: _currentLocation == null + ? null + : (o) => _calculateDistanceFromCurrent(o), ); - - if (_currentLocation != null && ordersWithoutStep.isNotEmpty) { - ordersWithoutStep.sort((a, b) { - final distA = _calculateDistanceFromCurrent(a); - final distB = _calculateDistanceFromCurrent(b); - return distA.compareTo(distB); - }); + _orderSource = source; + if (!source.isAdmin) { + RouteOrder.logUnsequenced('deliveries', bookings.length); } - - return [...ordersWithStep, ...ordersWithoutStep]; + return ordered; } int _getDisplayStepNumber(List> allOrders, int index) { @@ -790,9 +1021,11 @@ class _MyPickupsState extends State // Run these in parallel _restoreSkips(); + _restoreClosedToday(); _restoreActivePickup(); _initializeLocation(); // Don't block _fetchPicked(); + _loadBagLabels(); _startPollingStream(); } @@ -832,6 +1065,42 @@ class _MyPickupsState extends State } } + /// Re-loads the stops already closed today, so a restart cannot put one + /// back on this tab. + /// + /// ── The reappearing skip ── + /// + /// [_completedOrderKeys] is what stops the 3-second poller resurrecting a + /// stop the rider has just finished, and it was memory-only. That is enough + /// for a delivery, because the backend agrees within a poll or two — the + /// consignment goes `Delivered` and the row stops being delivery work. + /// + /// It is **not** enough for a skip. A skipped stop is a failed attempt, not + /// a closed consignment: the server rightly leaves it `Out_for_Delivery`, + /// so the row keeps coming back as live delivery work and only this set was + /// holding it off the list. Kill the app, and the set went with it — the + /// stop the rider wrote off an hour ago was back at the top of his queue, + /// while its record sat in Activity saying it was done. Two screens, two + /// answers, and the one he had to act on was the wrong one. + /// + /// The completed store already holds exactly the right rows: what this + /// device closed, today, under this rider's scope, pruned nightly. Reading + /// it back is the whole fix. + Future _restoreClosedToday() async { + try { + final closed = await getCompletedBookings(); + if (closed.isEmpty || !mounted) return; + setState(() { + for (final b in closed) { + _completedOrderKeys.add(_getOrderKey(b)); + } + }); + debugPrint('[MYPICKUPS] Restored ${closed.length} closed stop(s)'); + } catch (e) { + debugPrint('[MYPICKUPS] Could not restore closed stops: $e'); + } + } + Future _restoreActivePickup() async { try { final prefs = await SharedPreferences.getInstance(); @@ -1091,6 +1360,197 @@ class _MyPickupsState extends State return _activePickups.isNotEmpty; } + /// The live stops this screen is allowed to open — see + /// [MilkRun.worksOnOwnScreen]. + /// + /// Not the same question as [_hasActivePickup]. That one is about the rider + /// (is a job running at all — it drives the poll rate, the wakelock and the + /// exit warning) and stays as it is. This one is about *this screen*: whether + /// the running job is one it can take him into. + List> get _liveOnThisScreen => [ + for (final p in _activePickups) + if (MilkRun.worksOnOwnScreen(p, collectedIds: _collectedIds)) p, + ]; + + // ══════════════════════════════════════════════════════════════════════ + // START DELIVERY — the load leaves the counter + // + // ── Why this is a press and not a side effect of the pickup ── + // + // The release used to ride along with `pickup-complete`, back on Home: the + // rider slid to confirm five bags at a kitchen and the same gesture posted + // all five consignments `Out_for_Delivery`. The hub's board then showed five + // *active* deliveries for food still on the shelf in front of him, and there + // was no moment left in the day that meant "I have set off" — picked and + // out-for-delivery had been collapsed into one claim. + // + // They are two different claims about where the load is, made minutes or an + // hour apart, and only the rider can make the second one. So it is his press, + // on the tab that holds the load. + // + // ── Why one bar for the whole load ── + // + // `POST /miler/deliveries/start` takes a batch because the gesture is one + // press after the last counter. Releasing per card would be five presses for + // one act and would leave the round half-open — some rows deliverable, some + // refused at the door by a server the rider cannot argue with. + // ══════════════════════════════════════════════════════════════════════ + + /// Orders in the rider's hands that he has not yet declared himself riding. + /// + /// The bar's subject, and the reason it appears at all: an empty answer means + /// the whole load is already on the road and there is nothing left to press. + /// + /// Empty on every line but the milk run, and deliberately: a logistics parcel + /// is collected *for the hub* and delivered by somebody else, so its rider + /// has no round to start and must never be shown a control that says he has. + /// [MilkRun.navigatesToCustomer] asks the same question one stop at a time. + List> get _unreleased => !_isMilkRun + ? const [] + : [ + for (final s in _visibleStops) + // The same three exclusions the queue itself applies, so the bar + // can never count a row the rider cannot see: work he has already + // closed, work he has put down, and work already on the road. + if (!stopStatusOf(s).isWorkComplete && + !stopStatusOf(s).isCancelled && + !stopStatusOf(s).isSkipped && + !_completedOrderKeys.contains(_getOrderKey(s)) && + _needsRelease(s)) + s, + ]; + + /// Whether this stop is still waiting on the rider's **Start delivery**. + /// + /// ── Read from the consignment, not from a flag in this app ── + /// + /// Two backend behaviours are live at once and the app must not care which + /// it is talking to. `pickup-complete` either leaves a hyperlocal + /// consignment `Out_for_Delivery` (as it does today) or stops it at + /// `Collected_By_Miler` and waits for `start-delivery` — a server-side + /// setting, not something this app is told about. + /// + /// So the row is asked. `consignmentstatus` ships on every + /// `GET /miler/bookings` row, and it answers the question exactly: needing + /// release means needing release, and already out for delivery means the bar + /// has nothing to offer. **No feature flag is mirrored here**, because a + /// mirrored flag is a second source of truth that can be wrong. + /// + /// The local sets are the fallback for a row that carries no consignment + /// status yet — an older deployment, or a stop collected seconds ago that + /// the queue has not caught up with. + bool _needsRelease(Map stop) { + final state = consignmentStateFromRaw(stop['consignmentstatus']); + if (state != ConsignmentState.unknown) { + return state.needsRelease; + } + final id = MilkRun.idOf(stop); + return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id); + } + + /// True while the round is being marked started, so the bar cannot be + /// double-pressed. + bool _starting = false; + + /// Sets off: releases each collected consignment for delivery, server first. + /// + /// ── This is a real write now ── + /// + /// It has been three different things, and the history matters because two + /// of them were wrong in opposite directions. + /// + /// It began as `POST /miler/deliveries/start` — a route that is not on the + /// contract and not in the backend's table. It 404'd every time, so the bar + /// could never succeed. It was then cut back to a purely local record, on + /// the evidence that `pickup-complete` was itself pushing hyperlocal work + /// to `Out_for_Delivery`: the load really was released before it reached + /// this tab, and the press only recorded what the rider had done with it. + /// + /// The backend closed that gap on 21 Aug 2026. `pickup-complete` now stops + /// at `Collected_By_Miler` — collected, in the rider's hands, not yet on the + /// road — and `POST /miler/consignments/:id/start-delivery` makes the + /// release. So the press has a server meaning again, and the hub stops + /// seeing "out for delivery" for food still on the kitchen counter. + /// + /// **The local set is written only for what the server released.** A stop + /// whose consignment could not be reached or was refused keeps its PICKED + /// word and stays on the bar, because the alternative — showing ACTIVE for + /// a consignment the hub still calls collected — is the fiction that made + /// `deliver` fail at the door with nothing on screen to explain why. + /// + /// Returns the number of orders actually released. + Future startRound(List> stops) async { + if (_starting || stops.isEmpty) return 0; + setState(() => _starting = true); + try { + final released = []; + var unreachable = 0; + + for (final stop in stops) { + final orderId = MilkRun.idOf(stop); + if (orderId.isEmpty) continue; + if (await releaseForDelivery(stop)) { + released.add(orderId); + } else { + unreachable++; + } + } + _unreleasedFailures = unreachable; + if (released.isEmpty) return 0; + + await addOutForDeliveryOrderIds(released); + await stampOrderEvents(released, OrderEvent.outForDelivery); + if (mounted) { + setState( + () => _outForDeliveryIds = {..._outForDeliveryIds, ...released}, + ); + } + return released.length; + } catch (e) { + debugPrint('[MILKRUN] could not record the round starting: $e'); + return 0; + } finally { + if (mounted) setState(() => _starting = false); + } + } + + /// How many stops the last [startRound] could not release. Read once by + /// [_startWholeRound] to word the message honestly. + int _unreleasedFailures = 0; + + /// Scroll extent to reserve under the queue for whatever floats over it. + /// + /// Reads the same two conditions the slot itself reads, so the floor and the + /// surface can never disagree about which one is on screen. + double _bottomReservation(BuildContext context) => bottomReservationFor( + inset: BottomPage.bottomInset(context), + live: _liveOnThisScreen.isNotEmpty, + unreleased: _unreleased.isNotEmpty, + idle: 8.h, + ); + + Future _startWholeRound() async { + final stops = _unreleased; + final n = await startRound(stops); + if (!mounted) return; + + final held = _unreleasedFailures; + if (n == 0) { + AppFeedback.errorGlobal( + held == 1 + ? 'Could not start that delivery. Check your connection and try ' + 'again.' + : 'Could not start the round. Check your connection and try again.', + ); + return; + } + AppFeedback.successGlobal( + held > 0 + ? '$n started · $held could not be started' + : (n == 1 ? 'Delivery started' : '$n deliveries started'), + ); + } + /// Marks a stop as finished (picked up or cancelled) in local state right /// away, so the UI unblocks without waiting for the backend/poll to catch up: /// it leaves the active banner, drops out of the top list, and no longer @@ -1098,20 +1558,46 @@ class _MyPickupsState extends State void markPickupFinished( Map order, { bool cancelled = false, + + /// The exact word to file this stop under, when "cancelled" would be a + /// lie. A skipped delivery is the case: it is not a completion and not a + /// cancellation, and Activity has a slice for it. + String? terminalStatus, }) { final key = _getOrderKey(order); final orderId = (order['orderid'] ?? '').toString(); _completedOrderKeys.add(key); _stopPickupPosting(orderId); + // ── The bonus, before it is overwritten by the next stop ── + // + // `PickupsController` computes it when the stop closes and holds it in one + // observable that the *next* completion replaces. It was read once, to + // animate a number on the done screen, and then lost — so Activity could + // never answer "what was that one worth". Carried onto the record here, + // which is the only moment it is still this stop's. + try { + final earned = Get.isRegistered() + ? Get.find().lastBonusPoints.value + : 0; + if (earned > 0) order['bonuspts'] = earned; + } catch (_) { + // No controller registered — a stop closed from a screen that never + // created one. The row simply has no bonus on it. + } // Write it down. This is the only moment the app knows the stop is // finished — the status call is optimistic and the queue endpoints will not // report it for a poll or two, if at all on demo data — so without this the // Activity tab has nothing to show for a shift's work. See // [addCompletedBookings]. - addCompletedBookings([order], cancelled: cancelled) + addCompletedBookings( + [order], + cancelled: cancelled, + terminalStatus: terminalStatus, + ) .then( (_) => debugPrint( - '[ACTIVITY] recorded $orderId as ${cancelled ? 'cancelled' : 'picked'}', + '[ACTIVITY] recorded $orderId as ' + '${terminalStatus ?? (cancelled ? 'cancelled' : 'picked')}', ), ) // Fire-and-forget, but not silently: if this write ever fails the @@ -1124,6 +1610,32 @@ class _MyPickupsState extends State // (and as a "next stop") after a rebuild, since the backend may still report // it in a post-accept state. if (orderId.isNotEmpty) removeAcceptedBookings([orderId]); + // A collected order is no longer being carried once it is through a door. + // Left in the set it would keep the card off Home for the rest of the + // install, and tomorrow's run would inherit it. + if (orderId.isNotEmpty) removeCollectedOrderIds([orderId]); + // …and it is no longer on the round. Same reason: an id left in the + // released set keeps a delivered stop on this tab for the life of the + // install. + if (orderId.isNotEmpty) removeOutForDeliveryOrderIds([orderId]); + + // ── "Delivery 3 of 10 done" ── + // + // A meal run is one motion repeated: door, hand over, next. What a rider + // cannot see from inside it is how much is left, and a queue that silently + // gets shorter answers that only if he stops to count. Naming the position + // is the difference between ten identical stops and visible progress. + // + // Counted before the setState below removes it, so the number he reads is + // the one he just finished, not the one after. + if (ServiceProfile.active.handsOffAtCollection && !cancelled) { + final total = _picked.length + _completedToday.length; + final done = _completedToday.length + 1; + AppFeedback.successGlobal( + total > 0 ? 'Delivery $done of $total done' : 'Delivery done', + ); + } + if (mounted) { setState(() { _activePickups.removeWhere((o) => _getOrderKey(o) == key); @@ -1136,7 +1648,14 @@ class _MyPickupsState extends State // the exact moment he looks at it. if (!_completedToday.any((o) => _getOrderKey(o) == key)) { final stamped = Map.from(order); - stamped['orderstatus'] = cancelled ? 'cancelled' : 'picked'; + // The same terminal verb the completed store writes — a milk-run + // stop ends `delivered`, a logistics one `picked`. The two must + // agree or the rail and the store disagree about the same stop. + stamped['orderstatus'] = cancelled + ? 'cancelled' + : (ServiceProfile.active.deliversToCustomer + ? 'delivered' + : 'picked'); _completedToday.insert(0, stamped); } }); @@ -1169,7 +1688,10 @@ class _MyPickupsState extends State // Single source of truth — same resolution the Home screen uses. final bool online = await DutyController.to.load(); if (!online && mounted) { - AppFeedback.info(context, "You're offline. Go online from Home to start pickups."); + AppFeedback.info( + context, + "You're offline. Go online from Home to start pickups.", + ); } return online; } @@ -1177,6 +1699,26 @@ class _MyPickupsState extends State Future startPickup(Map item) async { if (!mounted) return; + // ── A collection is not worked from this screen ── + // + // On a kitchen line the rungs up to *picked up* belong to Home, in bulk: + // the rider is handed a stack of bags at one counter and slides once for + // all of them. Opening this stop's own map/arrive/confirm screen for a + // collection gave him a second, contradictory way to work the same rung — + // and it posted the booking ACTIVE from a tab that is supposed to be his + // load, so a stop he had not collected started reporting as live. + // + // Refused before the status write below, not after it. See + // [MilkRun.worksOnOwnScreen]. + if (!MilkRun.worksOnOwnScreen(item, collectedIds: _collectedIds)) { + AppFeedback.info( + context, + 'Collections are confirmed on Home — select the orders at the ' + 'kitchen and slide to update.', + ); + return; + } + // Online-gated: only an on-duty rider can start a pickup/delivery. if (!await ensureOnlineForAction()) return; if (!mounted) return; @@ -1209,6 +1751,24 @@ class _MyPickupsState extends State ); } + // ── Opening a stop starts its leg, if the bar has not already ── + // + // START DELIVERY is the normal way in and releases the whole load at once. + // This is the other way in: a rider who taps a card without pressing the + // bar is just as much on his way, and the stop has to be released before he + // gets there — `deliver` refuses any consignment that is not + // `Out_for_Delivery`, so opening one unreleased would walk him to a door + // and fail him at it. + // + // The same call the bar makes, for one order. [startRound] writes the local + // marker only after the server agrees, so a failure here leaves the row + // reading PICKED rather than lying about a round the hub has not started. + if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) && + !_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) { + await startRound([item]); + if (!mounted) return; + } + await openScreen( context, _PickupMapScreen(pickup: item, parentState: this), @@ -1245,6 +1805,10 @@ class _MyPickupsState extends State } _fetching = true; + // The bag each order is in, re-read with the list it labels: a pickup that + // happened on Home a moment ago must show its bags here without the rider + // pulling to refresh. + unawaited(_loadBagLabels()); try { final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; @@ -1340,27 +1904,70 @@ class _MyPickupsState extends State // those locally recorded as accepted, or already in a post-accept backend // state. Anything still pending/assigned is dropped from Bookings. final acceptedIds = await getAcceptedOrderIds(); - var dedupedList = items.where((o) { - final id = (o['orderid'] ?? '').toString(); - if (id.isNotEmpty && acceptedIds.contains(id)) return true; - final status = (o['orderstatus']?.toString().toLowerCase() ?? '') - .trim(); - const pending = {'', 'assigned', 'miler_assigned', 'pending', 'new'}; - return !pending.contains(status); - }).toList(); - // ── Debug-only demo fallback ─────────────────────────────────────── + // ── This screen does not decide what it owns ── // - // Gated on the list that actually reaches the UI, not on the raw API - // response — which is what was still leaving this tab blank. + // [WorkBoundary] does, for both work screens at once, out of the shared + // day. That is the whole point of it: the gate used to be written here as + // a `where` over the raw statuses, Home decided the same thing its own + // way, and the two could — and did — both claim an accepted booking. // - // The backend answers a signed-in miler with his ASSIGNED bookings, so - // `items` is rarely empty. But assigned work belongs on Home until he - // accepts it, and the filter directly above drops exactly that. The - // result: a non-empty API response, an empty screen, and a fallback - // keyed on `items.isEmpty` that therefore never fired. Checking after - // the filter is the only check that matches what the rider sees. + // Where the boundary sits still follows the line, exactly as it did: + // logistics hands over at acceptance, a kitchen line at pickup-complete. + // What changed is that the rule is declared once, on + // [ServiceProfile.handoffAt], instead of being re-derived on each screen. // + // ── What this gate is deliberately NOT ── + // + // It is not the *released* set. Being on this tab and being deliverable + // are two different things: the round does not open until the rider + // presses START DELIVERY. That gate lives on the rows and on the bar at + // the foot of this screen — see [_deliveryLocked] and [MilkRun] — rather + // than on the fetch, because hiding the orders until release left them on + // no tab at all: dropped from Home as collected, withheld from here as + // unreleased. + final Set collectedIds = await getCollectedOrderIds(); + + // ── Held on the state whatever the line ── + // + // Every "which leg is this stop on?" question on this screen and in the + // sheets it opens reads this set — see [MilkRun.workingKind] and the + // sheet's `_carrying`. It used to be assigned only inside the milk-run + // branch below, so on any other line those questions were asked against + // an empty set and had to fall back to guessing from the raw status. + if (mounted && !setEquals(_collectedIds, collectedIds)) { + setState(() => _collectedIds = collectedIds); + } + + // The whole day and the sets the round is gated on. Held on the state so + // the bar at the foot of this screen and the lock on each row read the + // same facts the fetch just established. + if (_isMilkRun) { + final outForDeliveryIds = await getOutForDeliveryOrderIds(); + final notLoadedIds = await getNotLoadedOrderIds(); + final rejectedIds = await getRejectedOrderIds(); + if (mounted) { + setState(() { + _allDayStops = items.whereType>().toList(); + _acceptedIds = acceptedIds; + // `_collectedIds` is set above, for every line. + _outForDeliveryIds = outForDeliveryIds; + _notLoadedIds = notLoadedIds; + _rejectedIds = rejectedIds; + }); + } + } + + // One call, one rule — see [WorkBoundary.deliveryQueue]. The second + // `where` that used to sit here, re-admitting rows by raw status, is gone + // with it: it was the hole an accepted-but-uncollected booking came + // through, and a second expression of a fact that now has one. + var dedupedList = WorkBoundary.deliveryQueue( + items.whereType>(), + collectedIds: collectedIds, + acceptedIds: acceptedIds, + ); + // Drop stops the rider just finished. The backend may still return them // as 'active'/'picked' for a poll cycle or two; without this they'd // reappear on the list and re-block the next stop. @@ -1408,15 +2015,25 @@ class _MyPickupsState extends State // (_picked.length) included completed stops the cards then rendered as // SizedBox.shrink — a "2 stops" ghost with no visible cards. They are // on the Activity tab. - // - SKIPPED stops leave too. A skip means "not now, come back later", - // which is not the same as work in hand: left here they sat among the - // live stops looking identical to them, and the rider had to remember - // which of his eight cards were real. They wait on Home, with the - // other undecided work, and resuming one there sends it back to this - // list as an ordinary accepted stop. + // - SKIPPED stops **stay**, under their own heading. + // + // They used to leave, and the reason was sound at the time: left in + // the list they sat among the live stops looking identical to them, + // and the rider had to remember which of his eight cards were real. So + // they were sent to Home to wait with the undecided work. + // + // That reason is gone. The queue is no longer one card repeated — a + // skipped stop now renders as its own density (see [QueueDensity]): + // under a SKIPPED heading, carrying the reason in warning colour, with + // an outlined Resume and no primary action. It cannot be mistaken for + // live work at a glance, which was the entire objection. + // + // Keeping it here is also where the rider looks for it. He skipped it + // twenty minutes ago on this screen, in this queue; sending it to a + // different tab to be resumed is a hunt for something he never moved. final listForTop = sortedList.where((o) { final st = stopStatusOf(o); - return !st.isActive && !st.isFinishedPickup && !st.isSkipped; + return !st.isActive && !st.isFinishedPickup; }).toList(); debugPrint( @@ -1433,6 +2050,10 @@ class _MyPickupsState extends State // on screen. The counts are now shown on the empty state itself, so the // failing stage is visible instead of guessed at. _fetchDiag = + // Which line the app resolved the rider onto. Three of the reports + // that landed here as "the tab is wrong" were really "the app thinks + // I am on the other line", and nothing on screen said so. + 'line=${ServiceProfile.active.line.name} ' 'api=${items.length} ' 'accepted=${acceptedIds.length} ' 'workable=${dedupedList.length} ' @@ -2205,7 +2826,6 @@ class _MyPickupsState extends State ); } - debugPrint('[PICKUPS][PICKUPLOG][POST] 📦 Payload: $payload'); debugPrint( '[PICKUPS][PICKUPLOG][POST] ✅ starttime in payload: "${payload['starttime']}"', @@ -2247,9 +2867,13 @@ class _MyPickupsState extends State final confirm = await showAppDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Pending Pickups'), - content: const Text( - 'Are you sure you want to close? There are pickups pending.', + // Same rule as the app bar above: a meal rider has deliveries + // pending, not pickups. `jobNounPlural` is 'deliveries' on that + // line and 'bookings' on a parcel route. + title: Text('Pending ${ServiceProfile.active.workTabLabel}'), + content: Text( + 'Are you sure you want to close? There are ' + '${ServiceProfile.active.jobNounPlural} pending.', ), actions: [ TextButton( @@ -2275,232 +2899,473 @@ class _MyPickupsState extends State // (#FCF9F8, a warm off-white); the difference is invisible under a full // list and obvious the moment the list is short, so the tab appeared to // change colour with the rider's workload. - backgroundColor: ColorConstants.pureSurface, + // The brand shows through the sheet's two top corners — see + // [MilerSheet]. + backgroundColor: ColorConstants.primary, appBar: MilerAppBar( - title: 'My Bookings', + // ── The tab and the page it opens must agree ── + // + // This was the literal 'My Bookings', while the nav tab underneath it + // already read the noun off the profile + // (`ServiceProfile.active.workTabLabel`, see `Bottom_page.dart`). So a + // meal rider tapped a tab called **Deliveries** and arrived on a page + // titled **My Bookings** — the one screen where the two words sit a + // thumb apart was the one screen that disagreed with itself. + // + // Same source as the tab now: "My Deliveries" on a meal run, "My + // Bookings" on a parcel route. See [ServiceProfile.jobNounPlural]. + title: 'My ${ServiceProfile.active.workTabLabel}', trailing: MapViewRow( pickup: _picked, preservedStepNumbers: _preservedStepNumbers, ), ), - body: Stack( - children: [ - // Three states, cross-faded: still asking, nothing to do, and the - // queue itself. The middle one used to cover the first, and the - // hand-off between any two of them used to be a single frame. - SmoothSwap( - child: _picked.isEmpty && !_firstLoadDone - ? const SkeletonList(key: ValueKey('bookings-loading')) - : _picked.isEmpty && _fetchFailed - ? ErrorRetry( - key: const ValueKey('bookings-error'), - onRetry: () => _fetchPicked(force: true), - ) - : _picked.isEmpty - ? SingleChildScrollView( - key: const ValueKey('bookings-empty'), - physics: const AlwaysScrollableScrollPhysics(), - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.72, - // One empty-state component across the app — see - // [MilerEmptyState]. This was hand-rolled with its own - // headline size, body size, animation size and padding, - // as was Activity's; three near-identical treatments for - // the state a new rider sees most in his first week. - child: MilerEmptyState( - icon: Icons.local_shipping_rounded, - illustration: Lottie.asset( - 'assets/lotties/all_caught_up.json', - fit: BoxFit.contain, - repeat: true, - errorBuilder: (_, __, ___) => Icon( - Icons.local_shipping_rounded, - size: 64.sp, - color: ColorConstants.borderStrong, + // ── This screen is cards on a ground; Home is rows on a surface ── + // + // Deliveries is the one work screen whose content genuinely *is* a + // stack of raised objects: the NOW card is a layer-2 command surface, + // and UP NEXT and LATER recede beneath it. A layer-2 surface needs a + // layer-0 ground to sit on, and this page was giving it white — so the + // card that is supposed to dominate the screen was separating from its + // page by 1.124 : 1, and had to carry a border and a shadow to be seen + // at all. + // + // Home and Activity keep the working layer, because they have no cards + // by design — rows on a spine. Same ladder, different rung, and the + // rung is chosen by what the screen actually draws. See + // [MilerSurface]. + body: MilerSheet( + color: MilerSurface.canvas, + child: Stack( + children: [ + // Three states, cross-faded: still asking, nothing to do, and the + // queue itself. The middle one used to cover the first, and the + // hand-off between any two of them used to be a single frame. + SmoothSwap( + child: _picked.isEmpty && !_firstLoadDone + ? const SkeletonList(key: ValueKey('bookings-loading')) + : _picked.isEmpty && _fetchFailed + ? ErrorRetry( + key: const ValueKey('bookings-error'), + onRetry: () => _fetchPicked(force: true), + ) + : _picked.isEmpty + ? SingleChildScrollView( + key: const ValueKey('bookings-empty'), + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.72, + // ── The artwork is the whole empty state ── + // + // This was a [MilerEmptyState] drawing a headline and a + // body line above the illustration — and the + // illustration has both baked into it, in the same + // words: "You're all caught up", then the line about + // new jobs appearing automatically. Rendered together + // the screen said each sentence twice, which reads as a + // rendering fault rather than as one message. The panel + // is the picture now, at a size worth looking at, and + // it keeps its own copy. + // + // [_fetchDiagLine] stays: it is `kDebugMode`-gated and + // folds away entirely in release, so nothing ships + // underneath the art. + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: 16.w, + ), + child: Image.asset( + 'assets/images/caught_up.png', + width: 330.w, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + LucideIcons.truck, + size: 64.sp, + color: ColorConstants.borderStrong, + ), + ), + ), + _fetchDiagLine(), + ], ), ), - title: "You're all caught up", - message: - 'New bookings will appear here automatically as ' - 'they come in.', - accent: ColorConstants.acceptGreen, - footer: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _fetchDiagLine(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 8.w, - height: 8.w, - child: CircularProgressIndicator( - strokeWidth: 2, - color: ColorConstants.acceptGreen, - ), - ), - SizedBox(width: 8.w), - Text( - 'Listening for new pickups…', - style: MilerType.label.on( - ColorConstants.acceptGreen, - ), - ), - ], - ), - ], - ), ), - ), - ) - : Column( - key: const ValueKey('bookings-list'), - children: [ - // No trip tabs here. Everything on this screen is already - // accepted, so the only live question is which stop is - // next — and that has one answer across the whole day. - // See [_visibleStops]. - _buildTripRail(), - Expanded( - child: RefreshIndicator( - onRefresh: _fetchPicked, - color: ColorConstants.primary, - child: _visibleStops.isEmpty - ? _buildTripTabEmptyState() - : ListView.builder( - padding: EdgeInsets.only( - top: 6.h, - bottom: _activePickups.isNotEmpty - ? 80.h - : 8.h, - ), - itemCount: _visibleStops.length, - itemBuilder: (context, index) { - final stops = _visibleStops; - final item = stops[index]; - final int displayStep = - _getDisplayStepNumber(stops, index); - // Live metres + speed drive the card's distance / - // ETA strip; RouteMetricsHelper picks the unit and - // falls back to an urban average speed when the GPS - // reading is unusable. - final double? distanceMeters = - RouteMetricsHelper.metersToStop( - item, - riderLat: - _currentLocation?.latitude, - riderLng: - _currentLocation?.longitude, - ); - final double? riderSpeedMps = - _currentLocation?.speed; - final String orderId = - (item['orderid'] ?? '').toString(); - final StopStatus status = stopStatusOf( - item, - ); - - // Logic for enabling cards: - // 1. Skipped bookings are ALWAYS enabled (so they can be un-skipped or completed) - // 2. The first non-skipped, non-cancelled, non-Picked up Booking is enabled - // 3. All other bookings are disabled - - final bool isSkipped = status.isSkipped; - // (Previously compared a lower-cased string to - // 'Picked up' — which could never match. Normalized - // status fixes that so completed stops truly hide.) - final bool isCancelledOrPickups = - status.isCancelled || status.isPicked; - final bool hasActive = _hasActivePickup(); - - // Don't show cancelled or Picked up bookings - if (isCancelledOrPickups) { - return const SizedBox.shrink(); - } - - // Skipped bookings: only enable when there is NO other active pickup - if (isSkipped && !isCancelledOrPickups) { - return Reveal( - key: ValueKey('reveal_$orderId'), - delay: staggerDelay(index), - child: PickupCard( - key: ValueKey('pickup_$orderId'), - item: item, - displayStep: displayStep, - distanceMeters: distanceMeters, - riderSpeedMps: riderSpeedMps, - enabled: !hasActive, - isSkipped: true, + ) + : Column( + key: const ValueKey('bookings-list'), + children: [ + // No trip tabs here. Everything on this screen is already + // accepted, so the only live question is which stop is + // next — and that has one answer across the whole day. + // See [_visibleStops]. + _buildTripRail(), + Expanded( + child: RefreshIndicator( + onRefresh: _fetchPicked, + color: ColorConstants.primary, + // ── An empty ROW list, not an empty stop list ── + // + // This asked `_visibleStops.isEmpty`, but the rows + // are built from a filtered subset of it. When the + // stops were all finished-but-not-yet-cleared, the + // page took the populated branch and rendered a + // `ListView` with zero children: a blank screen + // under the rail, with no explanation and nothing + // to pull on. Ask the list that is actually drawn. + child: _queueRows().isEmpty + ? _buildTripTabEmptyState() + : Builder( + builder: (context) { + final rows = _queueRows(); + return ListView.builder( + padding: EdgeInsets.only( + top: 2.h, + // Room for the live strip only when + // one is actually drawn — see + // [_liveOnThisScreen]. + // ── Room for whichever bar is + // drawn ── + // + // A flat 80 was the live strip's + // height, and the round bar is + // taller: a caption, a primary + // button and the nav-bar inset + // under it. The last card sat + // behind it with its controls + // unreachable. Measured from the + // pieces rather than guessed, so it + // cannot drift when either grows. + // ── One measured floor ── + // + // The unreleased branch was built + // from its bar's own pieces; the + // live branch was a flat `80.h` + // that forgot the device inset + // entirely, so on a gesture-nav + // phone the last row of the run sat + // behind the banner *and* behind + // the navigation. Both surfaces now + // state their own height and the + // floor reserves whichever one is + // actually showing, plus the inset. + bottom: _bottomReservation(context), ), + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + if (row.isHeading) { + return _QueueHeading( + label: row.heading!, + count: row.count, + // On the first heading only: + // it describes the whole + // queue, not the section. + order: index == 0 + ? _orderSource + : null, + ); + } + + final item = row.stop!; + final orderId = + (item['orderid'] ?? '') + .toString(); + // Live metres + speed drive the + // distance / ETA on every density; + // RouteMetricsHelper picks the unit + // and falls back to an urban average + // when the GPS reading is unusable. + final distanceMeters = + RouteMetricsHelper.metersToStop( + item, + riderLat: _currentLocation + ?.latitude, + riderLng: _currentLocation + ?.longitude, + // The leg decides the end: + // a delivery row measures + // to the DOOR. It measured + // to the kitchen, so the + // figure grew as the rider + // approached the customer. + toDrop: MilkRun.workingKind( + item, + collectedIds: _collectedIds, + ).isDelivery, + ); + + void open() => StopDetailSheet.show( + context, + stop: item, + stopNumber: row.displayStep, + // The pairing recorded at the + // counter — same store as the + // card, so the two agree. + bag: row.bag, + // The carried set — what turns + // a raw pickup row into the + // delivery leg the sheet must + // describe. + collectedIds: _collectedIds, + riderLat: + _currentLocation?.latitude, + riderLng: + _currentLocation?.longitude, + ); + + // ── One card shape per density ── + // + // Not one card dimmed four ways. See + // [QueueDensity] for why the live stop + // is physically bigger rather than + // merely brighter. + final Widget child = switch (row + .density) { + QueueDensity.now => PickupCard( + key: ValueKey( + 'pickup_$orderId', + ), + item: item, + displayStep: row.displayStep, + distanceMeters: distanceMeters, + riderSpeedMps: + _currentLocation?.speed, + bag: row.bag, + collectedIds: _collectedIds, + outForDeliveryIds: + _outForDeliveryIds, + ), + QueueDensity.next => _NextCard( + item: item, + bag: row.bag, + distanceMeters: distanceMeters, + riderSpeedMps: + _currentLocation?.speed, + onOpen: open, + ), + QueueDensity.later => _LaterRow( + item: item, + bag: row.bag, + distanceMeters: distanceMeters, + onOpen: open, + ), + QueueDensity.skipped => + _SkippedRow( + item: item, + bag: row.bag, + reason: row.reason, + onOpen: open, + onResume: () => + _resumeSkipped(item), + ), + }; + + return Reveal( + key: ValueKey('reveal_$orderId'), + delay: staggerDelay(index), + child: child, + ); + }, ); - } - - // Find the first non-skipped Booking to enable - int firstEnabledIndex = -1; - for (int i = 0; i < stops.length; i++) { - final orderStatus = - (stops[i]['orderstatus'] - ?.toString() - .toLowerCase() ?? - '') - .trim(); - if (orderStatus != 'skipped' && - orderStatus != 'cancelled' && - orderStatus != 'Picked up') { - firstEnabledIndex = i; - break; - } - } - - // Enable if this is the first available non-skipped Booking - // AND there is no active pickup running. - final bool enabled = - !hasActive && - firstEnabledIndex >= 0 && - index == firstEnabledIndex; - - return Reveal( - key: ValueKey('reveal_$orderId'), - delay: staggerDelay(index), - child: PickupCard( - key: ValueKey('pickup_$orderId'), - item: item, - displayStep: displayStep, - distanceMeters: distanceMeters, - riderSpeedMps: riderSpeedMps, - enabled: enabled, - isSkipped: false, - ), - ); - }, - ), + }, + ), + ), ), - ), - ], - ), - ), - // The live strip, shared with Home. It places itself clear of the - // floating nav bar, so it is pinned flush here and left to it. - if (_activePickups.isNotEmpty) + ], + ), + ), + // The live strip, shared with Home. It places itself clear of the + // floating nav bar, so it is pinned flush here and left to it. + // + // Only for a stop this screen can actually work. On a kitchen line + // a collection is Home's, in bulk, so a strip here offering to + // "continue" one was an invitation into a flow the rider is not + // supposed to be in — and the way most riders found it. See + // [_liveOnThisScreen]. + // ── The hinge of the day, at the foot of the tab that holds the + // load ── + // + // It shares this slot with the live strip and yields to it: once a + // stop is running, "continue that stop" is the only thing the rider + // wants from the bottom of this screen, and a second bar under his + // thumb offering to start something else is how the wrong one gets + // pressed. See [startRound]. + // ── One contextual slot, and the workflow advances through it ── + // + // These were two `if`s over the same corner. Logically exclusive + // already — a live stop suppresses the start bar, because a + // second bar under the rider's thumb offering to start something + // else is how the wrong one gets pressed — but they *swapped* by + // one disappearing and the other appearing in the same frame, + // which reads as the screen glitching rather than as **Start + // delivery → Continue this stop**. + // + // One switcher, one child at a time, on the app's own state + // duration. Keyed by which surface it is rather than by its + // contents, so a poll that returns the same live stop does not + // replay the transition. Positioned( left: 0, right: 0, bottom: 0, - child: ActivePickupBanner( - activePickups: _activePickups, - onTap: (pickup) async { - await startPickup(pickup); - // Refresh after returning from map screen - if (mounted) { - await Future.delayed(const Duration(milliseconds: 500)); - _fetchPicked(); - } - }, + child: AnimatedSwitcher( + duration: DesignConstants.motionState, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.12), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ), + child: _liveOnThisScreen.isNotEmpty + ? ActivePickupBanner( + key: const ValueKey('live'), + activePickups: _liveOnThisScreen, + onTap: (pickup) async { + await startPickup(pickup); + // Refresh after returning from map screen + if (mounted) { + await Future.delayed( + const Duration(milliseconds: 500), + ); + _fetchPicked(); + } + }, + ) + : _unreleased.isNotEmpty + ? StartRoundBar( + key: const ValueKey('start-round'), + count: _unreleased.length, + busy: _starting, + onPressed: _startWholeRound, + ) + : const SizedBox.shrink(key: ValueKey('none')), ), ), - ], + ], + ), ), ), ); } } + +/// ───────────────────────────────────────────────────────────────────────── +/// START DELIVERY — one press, the whole load +/// +/// The bar the rider hits after the last counter. It states what it is about to +/// act on — `5 orders in hand` — because the press releases every one of them, +/// and a control acting on a set the rider cannot see is one he learns not to +/// trust. +/// +/// ── Not a slide ── +/// +/// Slides are reserved for the writes a resting thumb must not be able to make: +/// "picked" is the rider asserting he is holding somebody's lunch, "delivered" +/// that he has handed it over. Setting off is neither irreversible nor a claim +/// about somebody else's property — pressing it early costs a hub board that +/// reads *active* a few minutes early — so it is a button, at the size of every +/// other primary action in the app. +/// +/// It sits above the floating nav bar rather than under it: [BottomPage] owns +/// that inset, and using it here is what keeps the button clear of the tab +/// strip on a tall phone. +/// How much scroll extent the queue must leave under itself. +/// +/// ── Why this is a function and not a number ── +/// +/// It was two branches and a magic constant: the unreleased case was built +/// from its bar's own pieces, and the live case was a flat `80.h` that forgot +/// the device inset entirely — so on a gesture-navigation phone the last row of +/// the run sat behind the banner *and* behind the navigation, with its controls +/// unreachable and no way to scroll to them. +/// +/// One rule, reading the same two conditions the slot itself reads, so the +/// floor and the surface can never disagree about which one is on screen. Each +/// surface states its own height, because the thing that overlays a list is the +/// only thing that knows how much of the list it covers. +double bottomReservationFor({ + required double inset, + required bool live, + required bool unreleased, + double idle = 8, +}) { + if (live) return inset + ActivePickupBanner.maxHeight; + if (unreleased) return inset + StartRoundBar.maxHeight; + return inset + idle; +} + +class StartRoundBar extends StatelessWidget { + /// The tallest this bar gets, excluding the device inset. See + /// [ActivePickupBanner.maxHeight] for why the surface declares its own. + static double get maxHeight => ButtonSizes.primary + 54.h; + + final int count; + final bool busy; + final VoidCallback onPressed; + + const StartRoundBar({ + super.key, + required this.count, + required this.busy, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + 16.w, + 12.h, + 16.w, + BottomPage.bottomInset(context) + 10.h, + ), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + boxShadow: [ + // The list scrolls under this, so the edge has to read as an edge — a + // shadow rather than a rule, for the same reason the route cards have + // one: a hairline is not there on a scratched screen in sun, and a + // rule across a scrolling list reads as a divider between two rows. + BoxShadow( + color: ColorConstants.slateText.withValues(alpha: 0.10), + blurRadius: 18, + offset: const Offset(0, -4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$count ${count == 1 ? 'order' : 'orders'} in hand', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + MilerButton( + label: 'Start delivery', + icon: LucideIcons.truck, + color: ColorConstants.primary, + loading: busy, + onPressed: busy ? null : onPressed, + ), + ], + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/pip.dart b/lib/views/Dashboard/pickups/pip.dart index 611b63a..c373a45 100644 --- a/lib/views/Dashboard/pickups/pip.dart +++ b/lib/views/Dashboard/pickups/pip.dart @@ -112,7 +112,7 @@ class _PipInfoCardState extends State { 'Out', style: TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -133,7 +133,7 @@ class _PipInfoCardState extends State { strokeCap: StrokeCap.round, textStyle: TextStyle( fontSize: 17, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -157,7 +157,7 @@ class _PipInfoCardState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/pickups/route_metrics.dart b/lib/views/Dashboard/pickups/route_metrics.dart index d6b1961..ec00653 100644 --- a/lib/views/Dashboard/pickups/route_metrics.dart +++ b/lib/views/Dashboard/pickups/route_metrics.dart @@ -158,9 +158,31 @@ class RouteMetricsHelper { Map stop, { double? riderLat, double? riderLng, + // ── Which end of the journey is "the stop"? ── + // + // This always read the pickup pair, which on the delivery leg measures + // the ride BACK to the kitchen the rider just left — the number grew as + // he approached the customer's door. The caller knows which leg it is + // rendering (the queue asks `MilkRun.workingKind`); this function cannot, + // so the leg arrives as a flag. Default false keeps every pickup-leg + // caller — Home's cards, the group nodes — exactly as it was. + // + // A delivery row whose payload carries no drop pair still falls back to + // the pickup pair: on a hyperlocal round the stop IS the customer's + // address in many payloads, and a wrong-ish number beats a dash the + // rider cannot plan with. + bool toDrop = false, }) { - final stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']); - final stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']); + double stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']); + double stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']); + if (toDrop) { + final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']); + final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']); + if (dLat != 0 && dLng != 0) { + stopLat = dLat; + stopLng = dLng; + } + } final fromLat = (riderLat != null && riderLat != 0) ? riderLat diff --git a/lib/views/Dashboard/pickups/sheet.dart b/lib/views/Dashboard/pickups/sheet.dart index f38ca20..173396c 100644 --- a/lib/views/Dashboard/pickups/sheet.dart +++ b/lib/views/Dashboard/pickups/sheet.dart @@ -18,6 +18,23 @@ class _PickupBottomSheet extends StatefulWidget { class _PickupBottomSheetState extends State<_PickupBottomSheet> with SingleTickerProviderStateMixin { + /// Orders the rider is carrying, as the queue screen knows them. + /// + /// "Picked up" is not a status here — the accepted store normalises these + /// rows to `accepted`, and the hand-over is recorded in the collected set. So + /// the leg has to be asked with that set in hand or a bag already in the box + /// reads as still needing collection. See [MilkRun.workingKind]. + Set get _carrying => + (widget.parentState ?? context.findAncestorStateOfType<_MyPickupsState>()) + ?._collectedIds ?? + const {}; + + /// Where this stop's *delivery* goes, or null when it carries no coordinates + /// for that leg. Read from [MilkRun] so this sheet, the route row and the map + /// screen cannot disagree about the destination. + ({double lat, double lng})? get _deliveryTarget => + MilkRun.navigationTarget(widget.pickup, collectedIds: _carrying); + String selectedOption = ''; bool isPickupsSelected = true; bool showOptions = false; @@ -73,20 +90,17 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> lastFailureTime = null; } - bool _hasCollection() { - final raw = widget.pickup['collectionamt']; - if (raw == null) return false; - if (raw is num) return raw > 0; - final parsed = double.tryParse(raw.toString()) ?? 0.0; - return parsed > 0; - } + // Both of these read the amount through [stopCollectionAmount] rather than + // reaching into the map themselves. + // + // They used to parse `collectionamt` inline, which meant they missed the + // `pickupamt` / `codamount` spellings the shared helper accepts — this sheet + // could show "No collection" on a stop the card behind it had priced. It is + // also the gate that retires cash entirely for a subscription tenant, and a + // second reader would have walked straight past it. + bool _hasCollection() => stopCollectionAmount(widget.pickup) > 0; - double _getCollectionAmount() { - final raw = widget.pickup['collectionamt']; - if (raw == null) return 0.0; - if (raw is num) return raw.toDouble(); - return double.tryParse(raw.toString()) ?? 0.0; - } + double _getCollectionAmount() => stopCollectionAmount(widget.pickup); double _getPickupAmount() { final raw = widget.pickup['pickupamt']; @@ -102,31 +116,41 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> required Color color, required VoidCallback onTap, }) { + // ── Tonal, not outlined ── + // + // Three bordered buttons stacked under one filled button is four rectangles + // competing on a sheet whose question has one likely answer. The rider + // delivered it; the other two are what he reaches for when something went + // wrong. + // + // A tinted fill with no border is the same treatment the state chips on the + // route use, so "amber means parked, red means it did not happen" is one + // vocabulary across the app rather than a per-screen invention — and it + // drops an edge from a surface that already has plenty. return SizedBox( height: ButtonSizes.secondary, - child: OutlinedButton.icon( + child: TextButton.icon( onPressed: onTap, - icon: Icon(icon, color: color, size: 18.sp), + icon: Icon(icon, color: color, size: 19.sp), label: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 14.sp, + fontSize: 14.5.sp, fontWeight: FontWeight.w700, + letterSpacing: -0.2, color: color, fontFamily: FontConstants.fontFamily, ), ), - style: OutlinedButton.styleFrom( - side: BorderSide(color: color.withValues(alpha: 0.4)), + style: TextButton.styleFrom( + backgroundColor: color.withValues(alpha: 0.10), + foregroundColor: color, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ButtonSizes.radius), ), - // The theme gives OutlinedButton `vertical: 16` for full-width form - // buttons. In a 48dp box that leaves a 16px content band, which was - // painting this row's 18sp icon at 16px — measured, and silent. - padding: EdgeInsets.symmetric(horizontal: 14.w), + padding: EdgeInsets.symmetric(horizontal: 12.w), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -138,7 +162,15 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> Widget build(BuildContext context) { // Delivery stops are prepaid: no payment step, the primary action is // "Delivered" (a straight confirm) rather than "Picked up" + payment. - final bool isDelivery = stopKindOf(widget.pickup).isDelivery; + // + // Asked of the *leg*, not the payload. The booking adapter stamps + // `type: pickup` on every row, so a milk-run order already in the rider's + // box still called itself a pickup — and this sheet asked him to confirm + // collecting something he was carrying. See [MilkRun.workingKind]. + final bool isDelivery = MilkRun.workingKind( + widget.pickup, + collectedIds: _carrying, + ).isDelivery; // Only the cancel path asks a question here now. Money is asked about once, // on the payment page — see [_onPickedUp]. @@ -148,12 +180,32 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> ? ColorConstants.acceptGreen : ColorConstants.errorRed; + // ── Name the thing the button actually does ── + // + // On logistics this press is not "confirm a pickup". It posts + // `pickup-complete`, which converts the booking into a live consignment + // with a tracking number and routes it to a hub — the shipment begins to + // exist here, and the rider is the one who starts it. The app has always + // done that and never said so, which is why the flow reads as five + // unrelated forms rather than one order being raised. + // + // A milk-run drop is a hand-over and keeps its own word; anything else + // stays "Confirm pickup". + final String confirmLabel = isDelivery + ? 'Confirm delivery' + : (ServiceProfile.active.initiatesShipment + ? 'Initiate order' + : 'Confirm pickup'); + final String modeLabel = isPickupsSelected ? (isDelivery ? 'Delivered' : 'Picked up') - : (isDelivery ? 'Undelivered' : 'Cancelled'); + // "Cancelled" was the rider cancelling a *booking*, which is the hub's + // word for it and not his. What happened at the door is that he did not + // get the parcel — see the action row below. + : (isDelivery ? 'Not delivered' : 'Not picked'); final IconData modeIcon = isPickupsSelected - ? Icons.check_circle_rounded - : Icons.cancel_rounded; + ? LucideIcons.circleCheck + : LucideIcons.circleX; // A reason list belongs to the cancel path alone. A completed pickup has // nothing left to ask: the payment page has already taken the method, the @@ -175,17 +227,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> mainAxisSize: MainAxisSize.min, children: [ // ── Handle + Header ───────────────────────────────────── - Center( - child: Container( - width: 40.w, - height: 4.h, - decoration: BoxDecoration( - color: ColorConstants.borderSubtle, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - ), - ), - SizedBox(height: 14.h), + const MilerSheetHandle(), Row( children: [ @@ -198,18 +240,20 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> ), decoration: BoxDecoration( color: confirmColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(modeIcon, color: confirmColor, size: 14.sp), - SizedBox(width: 5.w), + Icon(modeIcon, color: confirmColor, size: 15.sp), + SizedBox(width: 6.w), Text( modeLabel, style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, color: confirmColor, fontFamily: FontConstants.fontFamily, ), @@ -221,16 +265,23 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> ], Expanded( child: Text( + // ── This sheet is the confirmation page now ── + // + // Nothing opens before it any more: the rider taps "I've + // arrived" on the map and lands here. So the heading + // names the stop he is closing rather than announcing a + // status update, and the three answers underneath are + // the whole of what the app asks him at a door. showOptions ? (isPickupsSelected ? (isDelivery ? 'Confirm delivery' : 'Collect payment') - : 'Select reason') - : 'Update Status', + : 'What happened?') + : confirmLabel, style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), @@ -253,13 +304,11 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> child: Container( padding: EdgeInsets.all(8.r), decoration: BoxDecoration( - color: ColorConstants.neutralLight, + color: MilerSurface.canvas, shape: BoxShape.circle, ), child: Icon( - showOptions - ? Icons.arrow_back_rounded - : Icons.close_rounded, + showOptions ? LucideIcons.arrowLeft : LucideIcons.x, color: ColorConstants.onSurface, size: 18.sp, ), @@ -280,21 +329,62 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> // Three clear choices, one tap each. Primary = Picked up / // Delivered; then Skip and Cancel / Undelivered. if (showSlider) ...[ - // Primary: Picked up / Delivered + // ── Navigate, on the sheet the rider is actually looking at ── + // + // The delivery leg is the one where he is driving to an + // address he has never been to, and re-opening maps meant + // dismissing this sheet, finding the button on the screen + // behind it, and coming back. Every delivery app a rider has + // used keeps the journey action on the live-stop surface for + // exactly that reason. + // + // Only on the delivery leg: a collection is worked in bulk at + // one counter from Home, where the kitchen card carries its + // own Navigate. + // + // Hidden — not disabled — when the stop has no coordinates for + // this leg: a Navigate that opens a map on nothing is worse + // than none, because the rider only finds out after + // committing to follow it. Same rule as the route row's. + if (isDelivery && _deliveryTarget != null) ...[ + SizedBox( + width: double.infinity, + height: ButtonSizes.secondary, + child: MilerButton( + label: 'Navigate', + icon: LucideIcons.navigation, + color: ColorConstants.primary, + height: ButtonSizes.secondary, + onPressed: () { + final t = _deliveryTarget!; + openExternalNavigation(t.lat, t.lng); + }, + ), + ), + SizedBox(height: 10.h), + ], + // Primary: Picked up / Delivered. + // + // The three outcomes are the whole vocabulary of a stop: + // **Picked up** (he has it), **Not picked** (he was there and + // did not get it — the sheet then asks why), **Skipped** (he + // is parking it for a return visit). One tap chooses; nothing + // else is asked of him at the door. SizedBox( width: double.infinity, height: ButtonSizes.primary, child: ElevatedButton.icon( onPressed: () => _onPickedUp(isDelivery), icon: const Icon( - Icons.check_circle_rounded, + LucideIcons.circleCheck, color: Colors.white, ), label: Text( isDelivery ? 'Delivered' : 'Picked up', style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w800, + fontSize: 16.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -303,7 +393,9 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> backgroundColor: ColorConstants.acceptGreen, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), ), ), ), @@ -314,8 +406,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> children: [ Expanded( child: _sheetSecondaryButton( - label: 'Skip', - icon: Icons.skip_next_rounded, + label: 'Skipped', + icon: LucideIcons.clock, color: ColorConstants.warning, onTap: () async { final resolvedParentState = @@ -334,8 +426,14 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> SizedBox(width: 10.w), Expanded( child: _sheetSecondaryButton( - label: isDelivery ? 'Undelivered' : 'Cancel', - icon: Icons.cancel_rounded, + // "Cancelled" on the delivery leg, by product + // decision, and it is also what the press actually + // does: the reason list behind it posts a cancel. The + // pickup leg keeps "Not picked", which is the honest + // description of standing at a counter and being + // handed nothing. + label: isDelivery ? 'Cancelled' : 'Not picked', + icon: LucideIcons.circleX, color: ColorConstants.errorRed, onTap: () { setState(() { @@ -376,7 +474,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> child: Column( children: [ for (var i = 0; i < options.length; i++) - _ReasonRow( + MilerSheetChoiceRow( label: options[i], icon: _cancelIcon(options[i]), accent: confirmColor, @@ -416,7 +514,9 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> ), elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), ), ), child: isLoading @@ -435,8 +535,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> children: [ Icon( isPickupsSelected - ? Icons.check_rounded - : Icons.close_rounded, + ? LucideIcons.check + : LucideIcons.x, size: 22.sp, color: Colors.white, ), @@ -449,17 +549,15 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> Flexible( child: Text( isPickupsSelected - ? (isDelivery - ? 'Confirm delivery' - : 'Confirm pickup') + ? confirmLabel : (isDelivery - ? 'Mark undelivered' - : 'Cancel booking'), + ? 'Mark not delivered' + : 'Mark not picked'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -507,6 +605,30 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> Future _onPickedUp(bool isDelivery) async { if (isLoading || isProcessing) return; + // ── One press on a service run, two on a parcel ── + // + // The two-step exists because a parcel stop has something to confirm: what + // was collected, whether anything was owed, what the rider is signing off. + // The rider states it, then commits it. + // + // A subscription drop has none of that. He is at a door with one bag that + // is already paid for, and the whole chain is "I've arrived" → "I've picked + // up" → next. Making him press *Delivered* and then *Confirm delivery* is + // the same answer twice, ten times a lunchtime, for a stop that carries no + // decision at all. + if (ServiceProfile.active.handsOffAtCollection) { + setState(() { + isPickupsSelected = true; + selectedOption = isDelivery ? 'Delivered' : 'Picked up'; + showOptions = false; + showSlider = false; + isProcessing = false; + lastFailureTime = null; + }); + await _handleConfirm(); + return; + } + // Prepaid, or nothing to collect: there is no money question to ask, so go // straight to the deliberate confirm. if (isDelivery || !_hasCollection()) { @@ -551,8 +673,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> // // A failure here is worth telling him about, unlike the parcel step: this // one is his liability. - final bookingId = widget.pickup['pickupid'] ?? - widget.pickup['orderheaderid']; + final bookingId = + widget.pickup['pickupid'] ?? widget.pickup['orderheaderid']; if (bookingId != null) { final payRes = await UpdatePickupProvider().submitPayment( bookingId, @@ -577,11 +699,11 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> IconData _cancelIcon(String option) { final l = option.toLowerCase(); - if (l.contains('refused')) return Icons.person_off_outlined; - if (l.contains('reachable')) return Icons.phone_missed_rounded; - if (l.contains('switched')) return Icons.phone_disabled_rounded; - if (l.contains('location')) return Icons.location_off_rounded; - return Icons.cancel_outlined; + if (l.contains('refused')) return LucideIcons.userX; + if (l.contains('reachable')) return LucideIcons.phoneMissed; + if (l.contains('switched')) return LucideIcons.phoneOff; + if (l.contains('location')) return LucideIcons.mapPinOff; + return LucideIcons.circleX; } // ── ALL ORIGINAL BUSINESS LOGIC BELOW — UNTOUCHED ──────────────────────── @@ -625,7 +747,12 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> } final d = widget.pickup; - final bool isDelivery = stopKindOf(d).isDelivery; + // The same question the labels ask, so the button cannot say one thing and + // post another. See [MilkRun.workingKind]. + final bool isDelivery = MilkRun.workingKind( + d, + collectedIds: _carrying, + ).isDelivery; debugPrint('[CONFIRM] ===== PICKUP INFO ====='); debugPrint('[CONFIRM] pickupid: ${d['pickupid']}'); debugPrint('[CONFIRM] orderid: ${d['orderid']}'); @@ -748,26 +875,110 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> // Best-effort API + cleanup — must NOT prevent the success screen from // showing. With mock/offline data these can throw; we still complete // the pickup locally and move on to the success screen. + // + // With one exception, below: a stop the app *refused to send* is not a + // stop that failed to send. try { - apiSuccess = await dc.updatePickedupStatus( - pickupId: pickupIdInt, - orderHeaderId: orderHeaderId, - pickupLocationId: pickupLocationId, - smsPickup: 0, - ridersLat: riderLatStr, - ridersLng: riderLngStr, - pickupLat: pickupLat.toStringAsFixed(6), - pickupLng: pickupLng.toStringAsFixed(6), - notes: selectedOption, - pickupAmount: pickupAmount, - collectionAmount: collectionAmtToSend, - collectedAmount: collectedAmtToSend, - collectionStatus: collectionStatusToSend, - wasSkipped: wasSkipped, - orderId: orderId, - proofImage: '', - ); - debugPrint('[PICKUP][CONFIRM] result=$apiSuccess'); + // ── Which ending this stop has ── + // + // On a milk run this tab is the rider's *round*, so closing a stop + // here is a hand-over at a customer's door, not a collection: it + // writes `deliver` against the consignment. Sending `pickup-complete` + // instead would re-collect an order that is already in his hands and + // file the crate as the finished job. + // + // A logistics stop closes exactly as it always has. + if (ServiceProfile.active.deliversToCustomer) { + final dropLat = parseD(d['droplat'] ?? d['DropLat'] ?? 0); + final dropLng = parseD(d['droplon'] ?? d['DropLon'] ?? 0); + // ── Resolved, never read raw ── + // + // `GET /miler/bookings` does not return `consignmentid` at all + // (verified live 2026-08-20), so the raw key this used to read + // was empty on every fresh session and `updateDeliveredStatus` + // refused before anything left the phone — the rider pressed + // Delivered and was told "Could not record delivered" with the + // network never touched. [resolveConsignmentId] is the same + // four-source chain `closeDelivery` uses: the row, the pivot's + // local record, then the assignment row on the backend, which + // does carry the id. + final consignmentId = await resolveConsignmentId(d); + if (consignmentId.isEmpty) { + // The pickup never converted this booking — deliver cannot + // exist yet. Same message the queue's own close path shows. + apiSuccess = false; + if (mounted) { + AppFeedback.error( + context, + 'This order was never picked up on the system — mark it ' + 'picked up first, then deliver.', + ); + } + } else { + apiSuccess = await dc.updateDeliveredStatus( + pickupId: pickupIdInt, + consignmentId: consignmentId, + deliveredToName: + (d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer') + .toString(), + ridersLat: riderLatStr, + ridersLng: riderLngStr, + // Fall back to the stop's own coordinates when the payload + // carries no separate drop point: on a milk run the stop IS the + // customer's address, so they are the same place. + dropLat: (dropLat != 0 ? dropLat : pickupLat).toStringAsFixed( + 6, + ), + dropLng: (dropLng != 0 ? dropLng : pickupLng).toStringAsFixed( + 6, + ), + notes: selectedOption, + ); + } + debugPrint('[DELIVERY][CONFIRM] result=$apiSuccess'); + } else { + apiSuccess = await dc.updatePickedupStatus( + pickupId: pickupIdInt, + orderHeaderId: orderHeaderId, + pickupLocationId: pickupLocationId, + smsPickup: 0, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + notes: selectedOption, + pickupAmount: pickupAmount, + collectionAmount: collectionAmtToSend, + collectedAmount: collectedAmtToSend, + collectionStatus: collectionStatusToSend, + wasSkipped: wasSkipped, + orderId: orderId, + proofImage: '', + ); + debugPrint('[PICKUP][CONFIRM] result=$apiSuccess'); + } + + // ── Refused, not failed ── + // + // The rider is not at the address, so nothing was sent and nothing + // will be. Completing locally here is what made the app show the + // done screen, drop the stop off Bookings and leave Home holding a + // pickup the hub had never been told about — the rider's phone said + // picked, the office said active, and only the office was right. + // + // He keeps the sheet, the stop and a message saying how far he still + // has to go. See [PickupsController.lastBlockedReason]. + if (!apiSuccess && dc.lastBlockedReason != null) { + debugPrint('[PICKUP][CONFIRM] blocked: ${dc.lastBlockedReason}'); + lastFailureTime = null; + if (mounted) { + setState(() { + isLoading = false; + isProcessing = false; + }); + } + return; + } await _cachePickupsCancelled(pickupId, orderId, isPickups: true); await _cleanupAfterCompletion( @@ -836,6 +1047,22 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> ); debugPrint('[CANCEL][CONFIRM] result=$apiSuccess'); + // Same rule as the pickup branch: a proximity block is the app + // declining to send, not the wire failing. ("Incorrect location" is + // exempt from the fence upstream — a rider reporting that the address + // is wrong is by definition not standing at it.) + if (!apiSuccess && dc.lastBlockedReason != null) { + debugPrint('[CANCEL][CONFIRM] blocked: ${dc.lastBlockedReason}'); + lastFailureTime = null; + if (mounted) { + setState(() { + isLoading = false; + isProcessing = false; + }); + } + return; + } + await _cachePickupsCancelled(pickupId, orderId, isPickups: false); await _cleanupAfterCompletion( orderId, @@ -865,7 +1092,10 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> // the button for four seconds, so a rider whose confirm threw pressed // it, watched nothing happen, pressed again and was told to wait — // indistinguishable from a dead button. - AppFeedback.error(context, "Couldn't complete that stop — check your signal and try again"); + AppFeedback.error( + context, + "Couldn't complete that stop — check your signal and try again", + ); } await Future.delayed(const Duration(seconds: 4)); @@ -1004,6 +1234,22 @@ class _StopBrief extends StatelessWidget { this.showRule = false, }); + /// `Bag 5 · Vidhya Kitchen`, whichever halves exist; the trimmed booking + /// reference only as a last resort. + String _subtitle(String orderId) { + final bag = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '') + .toString() + .trim(); + final source = stopSourceName(pickup); + final line = [ + if (bag.isNotEmpty) bag, + if (source.isNotEmpty) source, + ].join(' \u00b7 '); + if (line.isNotEmpty) return line; + if (orderId.isEmpty) return 'Booking'; + return '#${orderId.replaceFirst(RegExp(r'^DM-BK-', caseSensitive: false), '')}'; + } + @override Widget build(BuildContext context) { final kind = stopKindOf(pickup); @@ -1052,7 +1298,7 @@ class _StopBrief extends StatelessWidget { style: TextStyle( color: kind.accent, fontSize: 13.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -1068,20 +1314,30 @@ class _StopBrief extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 2.h), + // ── The subtitle is what the rider matches, not files ── + // + // This printed `Booking #DM-BK-…` — a routing reference in + // the second-loudest slot on three sheets, saying nothing a + // rider acts on at a door. The line now carries the bag and + // the counter it came off (`Bag 5 · Vidhya Kitchen`) — the + // pairing that stops the wrong lunch reaching the wrong + // door — and falls back to the trimmed reference only when + // the payload offers nothing better. The full id stays one + // tap away on the detail sheet, with its copy control. Text( - orderId.isNotEmpty ? 'Booking #$orderId' : 'Booking', + _subtitle(orderId), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1100,11 +1356,7 @@ class _StopBrief extends StatelessWidget { // rather than as two things that happen to be near each other. SizedBox( width: 40.w, - child: Icon( - Icons.location_on_rounded, - size: 20.sp, - color: kind.accent, - ), + child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent), ), SizedBox(width: 12.w), Expanded( @@ -1115,7 +1367,7 @@ class _StopBrief extends StatelessWidget { kind.locationLabel, style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.6, color: kind.accent, fontFamily: FontConstants.fontFamily, @@ -1129,7 +1381,7 @@ class _StopBrief extends StatelessWidget { style: TextStyle( fontSize: 13.sp, height: 1.35, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), @@ -1148,114 +1400,5 @@ class _StopBrief extends StatelessWidget { } } -/// ── One answer in a reason list ── -/// -/// Update Status and Skip ask the same shape of question — "why?", four or two -/// answers, one of them — and drew it two different ways, both as stacks of -/// outlined white cards on a white sheet with each icon inside a second box. -/// -/// A hairline separates, the tick answers, and selection is carried by a fill -/// rather than by a thicker outline. A fill is what survives sunlight; a 1.5px -/// border is what does not — see [ColorConstants.cardSurface]. -class _ReasonRow extends StatelessWidget { - final String label; - final IconData icon; - final Color accent; - final bool selected; - - /// The hairline above this row. Suppressed either side of the selected row so - /// it does not cut across the tint and turn the one chosen answer back into a - /// box. - final bool showRule; - - final VoidCallback? onTap; - - const _ReasonRow({ - required this.label, - required this.icon, - required this.accent, - required this.selected, - required this.showRule, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - if (showRule) - Divider( - height: 1, - thickness: 1, - indent: 33.w, - color: ColorConstants.borderSubtle, - ), - Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 11.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - color: selected - ? accent.withValues(alpha: 0.10) - : Colors.transparent, - ), - child: Row( - children: [ - Icon( - icon, - size: 19.sp, - color: selected ? accent : ColorConstants.secondaryText, - ), - SizedBox(width: 13.w), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 14.5.sp, - color: selected ? accent : ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - fontWeight: selected - ? FontWeight.w800 - : FontWeight.w600, - ), - ), - ), - // The tick appears rather than an empty ring sitting on every - // row: hollow circles read as more things to deal with, when - // only one of them is ever the answer. - AnimatedScale( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutBack, - scale: selected ? 1 : 0.6, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 180), - opacity: selected ? 1 : 0, - child: Container( - width: 22.w, - height: 22.w, - decoration: BoxDecoration( - color: accent, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check_rounded, - color: Colors.white, - size: 14.sp, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ); - } -} +// `_ReasonRow` was promoted to the sheet kit as [MilerSheetChoiceRow] +// the day the Reject sheet became its third caller. diff --git a/lib/views/Dashboard/pickups/shipment_capture.dart b/lib/views/Dashboard/pickups/shipment_capture.dart new file mode 100644 index 0000000..2173ee1 --- /dev/null +++ b/lib/views/Dashboard/pickups/shipment_capture.dart @@ -0,0 +1,904 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:miler/data/miler_api.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE SHIPMENT DESK — what a logistics rider settles at the customer's door. +/// +/// A logistics booking usually arrives half-known. The customer raised it from +/// a map pin and described the destination as a phone number and a landmark; +/// nobody has weighed anything. The rider standing in the sender's doorway is +/// the first person who can establish any of it, and everything downstream — +/// which hub it routes through, what it costs, what the customer pays now — +/// follows from what he writes here. +/// +/// ``` +/// FROM ──▶ TO ──▶ WEIGHT ──▶ DISTANCE ──▶ PRICE ──▶ (payment, then initiate) +/// ``` +/// +/// ── Why the four steps are on one screen ── +/// +/// They are not four decisions; they are one conversation, held once, standing +/// up. A wizard would make the rider commit to an address before he has been +/// told the weight, and re-pricing after a correction would mean walking back +/// through pages. Here every field feeds the same quote, and the quote +/// re-computes when any of them changes. +/// +/// ── What this screen does NOT do ── +/// +/// It does not take money and it does not initiate anything. It hands its +/// capture back to the caller, which sends the addresses, then the parcel +/// measurements, then the payment, and only then `pickup-complete`. That order +/// is the contract's, not a preference: `pickup-complete` converts the booking +/// into a consignment and reads the addresses to route it, so anything written +/// after it has nowhere to land. +/// +/// ── And it never invents a fare ── +/// +/// The price comes from `POST /pricing/check` against the real pincodes and the +/// real weight. When the table has no rule for a combination the screen says so +/// and refuses to continue, because the alternative — a locally computed number +/// that looks official on a doorstep — is how a rider ends up collecting the +/// wrong money in the company's name. +/// ───────────────────────────────────────────────────────────────────────── +class ShipmentCapturePage extends StatefulWidget { + final Map pickup; + + const ShipmentCapturePage({super.key, required this.pickup}); + + @override + State createState() => _ShipmentCapturePageState(); +} + +class _ShipmentCapturePageState extends State { + // ── FROM ── + final _fromAddress = TextEditingController(); + final _fromPincode = TextEditingController(); + + // ── TO ── + final _toAddress = TextEditingController(); + final _toPincode = TextEditingController(); + final _toCity = TextEditingController(); + + // ── The shipment ── + final _weight = TextEditingController(); + String _serviceType = 'Normal'; + String _category = 'General'; + + /// The rider's fix at the door, so the FROM pin is where he is standing + /// rather than where the customer dropped a marker last week. + double? _fromLat; + double? _fromLon; + + ShipmentQuote? _quote; + bool _quoting = false; + String? _quoteError; + + /// Debounces the quote: the weight field re-fires on every keystroke and + /// "1", "1.", "1.2" are three requests for one number. + Timer? _quoteDebounce; + + @override + void initState() { + super.initState(); + final d = widget.pickup; + + // Seeded from the booking so the rider is confirming, not transcribing. + // Most of the time the customer's address is right and he changes nothing. + _fromAddress.text = (d['pickupaddress'] ?? d['PickupAddress'] ?? '') + .toString(); + _fromPincode.text = (d['pickuppincode'] ?? '').toString(); + _toAddress.text = (d['dropaddress'] ?? d['DropAddress'] ?? '').toString(); + _toPincode.text = (d['droppincode'] ?? '').toString(); + + _fromLat = double.tryParse('${d['pickuplat'] ?? ''}'); + _fromLon = double.tryParse('${d['pickuplon'] ?? ''}'); + + _captureRiderPosition(); + + for (final c in [_fromPincode, _toPincode, _weight]) { + c.addListener(_scheduleQuote); + } + } + + @override + void dispose() { + _quoteDebounce?.cancel(); + for (final c in [ + _fromAddress, + _fromPincode, + _toAddress, + _toPincode, + _toCity, + _weight, + ]) { + c.dispose(); + } + super.dispose(); + } + + /// The FROM pin, taken where the rider is standing. + /// + /// Best-effort: a failed fix leaves the booking's own coordinates in place, + /// which is what the app had before. Never blocks the form. + Future _captureRiderPosition() async { + try { + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 6), + ), + ).timeout(const Duration(seconds: 7)); + if (!mounted) return; + setState(() { + _fromLat = pos.latitude; + _fromLon = pos.longitude; + }); + } catch (_) { + // Keep whatever the booking carried. + } + } + + double get _weightKg => double.tryParse(_weight.text.trim()) ?? 0; + + /// Pincodes are what the server derives the zone from, so a quote is + /// impossible without both — and a six-digit one is the only shape that can + /// be one. + bool get _canQuote => + _weightKg > 0 && + _fromPincode.text.trim().length == 6 && + _toPincode.text.trim().length == 6; + + void _scheduleQuote() { + _quoteDebounce?.cancel(); + if (!_canQuote) { + if (_quote != null || _quoteError != null) { + setState(() { + _quote = null; + _quoteError = null; + }); + } + return; + } + _quoteDebounce = Timer(const Duration(milliseconds: 600), _fetchQuote); + } + + Future _fetchQuote() async { + if (!_canQuote || _quoting) return; + setState(() { + _quoting = true; + _quoteError = null; + }); + + final res = await MilerApi.checkPrice( + weight: _weightKg, + serviceType: _serviceType, + pickupPincode: _fromPincode.text.trim(), + deliveryPincode: _toPincode.text.trim(), + category: _category, + ); + if (!mounted) return; + + if (!res.ok) { + setState(() { + _quoting = false; + _quote = null; + // The network failed, which is a "try again" — distinct from the table + // having no rule, which is not. + _quoteError = res.message.isEmpty + ? 'Could not reach pricing. Check your connection and retry.' + : res.message; + }); + return; + } + + final quote = ShipmentQuote.fromData(res.map, preferredCategory: _category); + setState(() { + _quoting = false; + _quote = quote.found ? quote : null; + _quoteError = quote.found + ? null + : 'No price is configured for this weight and route. ' + 'Call the hub before collecting.'; + }); + } + + /// ── The straight-line distance between the two ends ── + /// + /// Shown, not charged. The fare comes from the pricing table's zone, which + /// the server derives from the pincodes; this is the sanity check that tells + /// a rider he has typed a destination pincode from the wrong city before he + /// takes money for it. Labelled as approximate for exactly that reason — + /// claiming a road distance the app has not measured would be a worse lie + /// than showing none. + double? get _approxKm { + final toLat = double.tryParse('${widget.pickup['droplat'] ?? ''}'); + final toLon = double.tryParse('${widget.pickup['droplon'] ?? ''}'); + if (_fromLat == null || _fromLon == null) return null; + if (toLat == null || toLon == null || toLat == 0 || toLon == 0) return null; + return Geolocator.distanceBetween(_fromLat!, _fromLon!, toLat, toLon) / + 1000.0; + } + + bool get _isComplete => + _fromAddress.text.trim().isNotEmpty && + _fromPincode.text.trim().length == 6 && + _toAddress.text.trim().isNotEmpty && + _toPincode.text.trim().length == 6 && + _weightKg > 0 && + _quote != null; + + /// What is still missing, named. A disabled button with no reason is the + /// thing riders phone the hub about. + String get _blockingReason { + if (_fromAddress.text.trim().isEmpty) return 'Confirm the pickup address'; + if (_fromPincode.text.trim().length != 6) return 'Pickup pincode needed'; + if (_toAddress.text.trim().isEmpty) return 'Enter the delivery address'; + if (_toPincode.text.trim().length != 6) return 'Delivery pincode needed'; + if (_weightKg <= 0) return 'Weigh the parcel'; + if (_quoting) return 'Getting the price…'; + if (_quoteError != null) return _quoteError!; + return 'Waiting for the price'; + } + + void _submit() { + if (!_isComplete) { + AppFeedback.info(context, _blockingReason); + return; + } + final q = _quote!; + Navigator.pop(context, { + 'captured': true, + 'from': { + 'address': _fromAddress.text.trim(), + 'pincode': _fromPincode.text.trim(), + 'lat': _fromLat, + 'lon': _fromLon, + }, + 'to': { + 'address': _toAddress.text.trim(), + 'pincode': _toPincode.text.trim(), + 'city': _toCity.text.trim(), + 'lat': widget.pickup['droplat'], + 'lon': widget.pickup['droplon'], + }, + 'weight': _weightKg, + 'category': _category, + 'servicetype': _serviceType, + 'zone': q.zone, + // The figure the customer is about to be asked for. The payment screen + // takes it from here so the two can never disagree about the amount. + 'price': q.payable, + 'pricemin': q.minPrice, + 'pricemax': q.maxPrice, + 'currency': q.currency, + 'approxkm': _approxKm, + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.daylightSurface, + appBar: milerPageBar('Shipment details'), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _journeyCard(), + SizedBox(height: 14.h), + _parcelCard(), + SizedBox(height: 14.h), + _quotePanel(), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!_isComplete) + Padding( + padding: EdgeInsets.only(bottom: 8.h), + child: Text( + _blockingReason, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + PrimaryButton( + label: _quote == null + ? 'Continue to payment' + : 'Continue · ${_quote!.currency} ' + '${_quote!.payable.toStringAsFixed(0)}', + onPressed: _isComplete ? _submit : null, + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// ───────────────────────────────────────────────────────────────────── + /// THE JOURNEY, AS ONE OBJECT + /// + /// From and To were two headed groups of loose white fields on a grey page — + /// address, pincode, address, pincode, city — five pills with nothing + /// relating them. The rider reading it back to a customer had to hold the + /// pairing in his head, and the one fact the screen is actually about, that + /// this parcel goes from *here* to *there*, was never drawn. + /// + /// So it is drawn: one surface, one rail, a dot at the origin and a pin at + /// the destination, with each end's fields grouped under its own marker. + /// This is the shape every logistics app in the world uses for a journey, + /// and it is the shape because it is read in one glance. + /// ───────────────────────────────────────────────────────────────────── + Widget _journeyCard() { + return Container( + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.borderSubtle, width: 1.2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _leg( + marker: _RouteMarker.origin, + label: 'FROM', + children: [ + _field( + controller: _fromAddress, + hint: 'Pickup address', + icon: LucideIcons.house, + maxLines: 2, + ), + SizedBox(height: 8.h), + _field( + controller: _fromPincode, + hint: 'Pincode', + icon: LucideIcons.mailbox, + keyboardType: TextInputType.number, + maxLength: 6, + digitsOnly: true, + ), + ], + ), + _leg( + marker: _RouteMarker.destination, + label: 'TO', + children: [ + _field( + controller: _toAddress, + hint: 'Delivery address', + icon: LucideIcons.mapPin, + maxLines: 2, + ), + SizedBox(height: 8.h), + Row( + children: [ + Expanded( + child: _field( + controller: _toPincode, + hint: 'Pincode', + icon: LucideIcons.mailbox, + keyboardType: TextInputType.number, + maxLength: 6, + digitsOnly: true, + ), + ), + SizedBox(width: 8.w), + Expanded( + child: _field( + controller: _toCity, + hint: 'City', + icon: LucideIcons.building2, + ), + ), + ], + ), + ], + ), + ], + ), + ); + } + + /// One end of the journey: its marker, its name, its fields. + /// + /// The rail is drawn by the marker column rather than behind the row, so it + /// stretches to whatever height the fields take — an address that wraps to + /// two lines lengthens the line instead of breaking it. + Widget _leg({ + required _RouteMarker marker, + required String label, + required List children, + }) { + final origin = marker == _RouteMarker.origin; + final tint = origin + ? ColorConstants.pickupAccent + : ColorConstants.deliveryAccent; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 26.w, + child: Column( + children: [ + SizedBox(height: 3.h), + origin + ? Container( + width: 11.w, + height: 11.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: tint, width: 3), + ), + ) + : Icon(LucideIcons.mapPin, size: 15.sp, color: tint), + if (origin) + Expanded( + child: Container( + width: 2, + margin: EdgeInsets.symmetric(vertical: 4.h), + color: ColorConstants.borderStrong, + ), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: origin ? 14.h : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + color: tint, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + ...children, + ], + ), + ), + ), + ], + ), + ); + } + + /// What is being sent — weight, what it is, how fast. + /// + /// One surface rather than a heading and three loose blocks, for the same + /// reason as the journey above: these three answers are one description of + /// one parcel, and the price is computed from all three together. + Widget _parcelCard() { + return Container( + padding: EdgeInsets.fromLTRB(14.w, 15.h, 14.w, 16.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.borderSubtle, width: 1.2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.package, + size: 15.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 7.w), + Text( + 'THE PARCEL', + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + SizedBox(height: 12.h), + _field( + controller: _weight, + hint: 'Weight (kg)', + icon: LucideIcons.weight, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + SizedBox(height: 12.h), + _chips( + label: 'Category', + values: MilerApi.parcelCategories, + selected: _category, + onSelect: (v) { + setState(() => _category = v); + _scheduleQuote(); + }, + ), + SizedBox(height: 12.h), + _chips( + label: 'Service', + values: MilerApi.serviceTypes, + selected: _serviceType, + onSelect: (v) { + setState(() => _serviceType = v); + _scheduleQuote(); + }, + ), + ], + ), + ); + } + + /// The number the rider says out loud, at the size he can read it at. + /// + /// ── Why the total is not a table row ── + /// + /// This was four `label · value` rows of equal weight — distance, zone, + /// chargeable weight, price — with the price differing only by a bold. But + /// three of those exist to *explain* the fourth, and the fourth is the only + /// one that gets spoken to a customer. Read at arm's length on a doorstep, + /// a bolded 13sp among three 13sp rows is not a total. + /// + /// So the money leads at display size, on the accent, and the workings sit + /// under it as the supporting detail they are. + Widget _quotePanel() { + final km = _approxKm; + final quote = _quote; + final priced = quote != null && !_quoting && _quoteError == null; + + return Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h), + decoration: BoxDecoration( + color: priced + ? ColorConstants.acceptGreen.withValues(alpha: 0.06) + : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all( + color: priced + ? ColorConstants.acceptGreen.withValues(alpha: 0.28) + : ColorConstants.borderSubtle, + width: 1.2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.receiptText, + size: 15.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 7.w), + Text( + 'WHAT THE CUSTOMER PAYS', + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + SizedBox(height: 12.h), + if (priced) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + quote.currency, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 3.w), + Flexible( + child: Text( + quote.payable.toStringAsFixed(0), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 34.sp, + height: 1.05, + fontWeight: FontWeight.w800, + letterSpacing: -1.2, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + if (quote.isRange) ...[ + SizedBox(height: 2.h), + Text( + 'Band ${quote.minPrice.toStringAsFixed(0)}–' + '${quote.maxPrice.toStringAsFixed(0)} · charging the lower', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + SizedBox(height: 12.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 10.h), + _row('Zone', quote.zone), + SizedBox(height: 6.h), + _row('Chargeable weight', '${quote.weight} kg'), + SizedBox(height: 6.h), + ], + _row( + 'Distance', + km == null ? 'Not available' : '≈ ${km.toStringAsFixed(1)} km', + // Said out loud rather than left for the rider to assume: it is a + // straight line between two pins, not a road distance, and the + // fare does not come from it. + note: km == null ? null : 'straight line — the fare uses the zone', + ), + if (!priced) SizedBox(height: 12.h), + if (_quoting) + Row( + children: [ + SizedBox( + width: 14.w, + height: 14.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 10.w), + Text( + 'Getting the price…', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ) + else if (_quoteError != null) + InfoBanner( + icon: LucideIcons.circleAlert, + text: _quoteError!, + color: ColorConstants.errorRed, + ) + else if (_quote == null) + Text( + 'Enter both pincodes and the weight to get a price.', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + Widget _row( + String label, + String value, { + bool emphasis = false, + String? note, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Text( + value, + style: TextStyle( + fontSize: emphasis ? 17.sp : 13.sp, + fontWeight: emphasis ? FontWeight.w800 : FontWeight.w600, + color: emphasis + ? ColorConstants.primary + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + if (note != null) + Padding( + padding: EdgeInsets.only(top: 2.h), + child: Text( + note, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + } + + Widget _field({ + required TextEditingController controller, + required String hint, + required IconData icon, + TextInputType? keyboardType, + int maxLines = 1, + int? maxLength, + bool digitsOnly = false, + }) { + return TextField( + controller: controller, + keyboardType: keyboardType, + maxLines: maxLines, + maxLength: maxLength, + inputFormatters: digitsOnly + ? [FilteringTextInputFormatter.digitsOnly] + : null, + onChanged: (_) => setState(() {}), + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + counterText: '', + hintText: hint, + prefixIcon: Icon(icon, size: 18.sp), + filled: true, + fillColor: ColorConstants.pureSurface, + hintStyle: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.borderSubtle), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.primary, width: 1.4), + ), + ), + ); + } + + Widget _chips({ + required String label, + required List values, + required String selected, + required ValueChanged onSelect, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Wrap( + spacing: 6.w, + runSpacing: 6.h, + children: [ + for (final v in values) + GestureDetector( + onTap: () => onSelect(v), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 7.h, + ), + decoration: BoxDecoration( + color: v == selected + ? ColorConstants.primary + : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + border: Border.all( + color: v == selected + ? ColorConstants.primary + : ColorConstants.borderSubtle, + ), + ), + child: Text( + v, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: v == selected + ? ColorConstants.onAccent + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ], + ); + } +} + +/// Which end of a journey a marker draws. +enum _RouteMarker { origin, destination } diff --git a/lib/views/Dashboard/pickups/shipment_review.dart b/lib/views/Dashboard/pickups/shipment_review.dart new file mode 100644 index 0000000..1f60eac --- /dev/null +++ b/lib/views/Dashboard/pickups/shipment_review.dart @@ -0,0 +1,702 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// REVIEW & PAY — the last screen before a shipment exists. +/// +/// ``` +/// verify parcel ──▶ shipment desk ──▶ [ REVIEW & PAY ] ──▶ order created +/// everything on +/// one screen +/// ``` +/// +/// ── Why this replaced a review, a payment page and a confirm sheet ── +/// +/// The rider used to close a logistics stop across four screens: the verify +/// page's own review, then the shipment desk, then a confirm sheet, then a +/// payment page. Three of those asked him to look at the same facts again, and +/// the one that took the money was the furthest from the one that priced it — +/// so the number he quoted the customer and the number he collected were two +/// screens apart with no way to see them together. +/// +/// This is the single review: what is being shipped, where from, where to, what +/// it weighs, what it costs, and how the customer is paying — then one button +/// that says the amount out loud. **`Pay ₹150`**, not `Confirm`. A rider taking +/// cash on a doorstep should never have to remember what the total was. +/// +/// ── The button is the commit ── +/// +/// Pressing it does three things in the order the contract requires: records +/// the payment, then posts `pickup-complete`, which converts the booking into a +/// live consignment with a tracking number. Only then does the screen change. +/// If any of it fails the rider stays here with the reason — nothing is +/// reported as created that was not created. +/// ───────────────────────────────────────────────────────────────────────── + +/// How the customer paid. The wire values the payment endpoint expects. +enum ShipmentPayMethod { cash, upi } + +extension ShipmentPayMethodX on ShipmentPayMethod { + String get label => switch (this) { + ShipmentPayMethod.cash => 'Cash', + ShipmentPayMethod.upi => 'UPI', + }; + + IconData get icon => switch (this) { + ShipmentPayMethod.cash => LucideIcons.banknote, + ShipmentPayMethod.upi => LucideIcons.qrCode, + }; + + /// Lower-case, matching what `UpdatePickupProvider.submitPayment` maps from. + String get wireName => name; +} + +class ShipmentReviewPage extends StatefulWidget { + /// The stop being closed. + final Map pickup; + + /// What the shipment desk captured — from, to, weight, zone, price. + final Map shipment; + + /// Takes the money and creates the order. Returns null on success, or a + /// message to show the rider when it could not be completed. + /// + /// Injected rather than called directly so this page stays a widget: the + /// caller owns the provider, the geofence and the controller. + final Future Function(ShipmentPayMethod method, double amount) + onConfirm; + + /// Read once the order exists, for the receipt line. + final String Function() trackingNo; + + const ShipmentReviewPage({ + super.key, + required this.pickup, + required this.shipment, + required this.onConfirm, + required this.trackingNo, + }); + + @override + State createState() => _ShipmentReviewPageState(); +} + +class _ShipmentReviewPageState extends State + with TickerProviderStateMixin { + ShipmentPayMethod _method = ShipmentPayMethod.cash; + bool _busy = false; + bool _created = false; + String? _error; + + // ── Built in initState, deliberately, not as `late final` ── + // + // A `late final` controller is only constructed on first read — and on a + // review the rider abandons, the first read is `dispose()`. Creating a Ticker + // there looks up `TickerMode` on an element that is already deactivated, + // which throws while the tree is being torn down. Eager construction is the + // only ordering that is correct for every exit from this screen. + late final AnimationController _successController; + late final Animation _tickScale; + late final Animation _ringScale; + late final Animation _copyFade; + + @override + void initState() { + super.initState(); + _successController = AnimationController( + vsync: this, + duration: DesignConstants.motionCelebrate, + ); + _tickScale = CurvedAnimation( + parent: _successController, + // Overshoots and settles — the shape of a stamp coming down, which is + // what this moment is. A linear tick reads as a loading state that + // stopped. + curve: Curves.elasticOut, + ); + _ringScale = CurvedAnimation( + parent: _successController, + curve: const Interval(0, 0.55, curve: Curves.easeOutCubic), + ); + _copyFade = CurvedAnimation( + parent: _successController, + curve: const Interval(0.35, 1, curve: Curves.easeOut), + ); + } + + @override + void dispose() { + _successController.dispose(); + super.dispose(); + } + + double get _amount { + final v = widget.shipment['price']; + if (v is num) return v.toDouble(); + return double.tryParse('${v ?? ''}') ?? 0; + } + + String get _currency => (widget.shipment['currency'] ?? 'INR').toString(); + + /// `₹150` — the figure on the button and in the summary, from one place so + /// the two can never disagree. + String get _amountLabel { + final symbol = _currency == 'INR' ? '₹' : '$_currency '; + return '$symbol${_amount.toStringAsFixed(0)}'; + } + + String _s(dynamic v) => (v ?? '').toString().trim(); + + Map get _from => + (widget.shipment['from'] as Map?)?.cast() ?? {}; + Map get _to => + (widget.shipment['to'] as Map?)?.cast() ?? {}; + + Future _confirm() async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + + final failure = await widget.onConfirm(_method, _amount); + if (!mounted) return; + + if (failure != null) { + // Stayed here on purpose. The order does not exist, and a success + // animation over a failed create is the one thing this screen must never + // do — the rider would ride off believing a shipment had been raised. + setState(() { + _busy = false; + _error = failure; + }); + return; + } + + setState(() { + _busy = false; + _created = true; + }); + _successController.forward(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + // Once the order exists, going back would offer to create it twice. + canPop: !_created && !_busy, + child: Scaffold( + backgroundColor: ColorConstants.daylightSurface, + appBar: _created ? null : milerPageBar('Review & pay'), + body: SafeArea(child: _created ? _successView() : _reviewView()), + ), + ); + } + + // ── Review ───────────────────────────────────────────────────────────── + + Widget _reviewView() { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _routeCard(), + SizedBox(height: 14.h), + const SectionHeader(title: 'Shipment'), + SizedBox(height: 8.h), + _shipmentCard(), + SizedBox(height: 14.h), + const SectionHeader(title: 'Payment'), + SizedBox(height: 8.h), + _paymentCard(), + if (_error != null) ...[ + SizedBox(height: 14.h), + InfoBanner( + icon: LucideIcons.circleAlert, + text: _error!, + color: ColorConstants.errorRed, + ), + ], + ], + ), + ), + ), + _payBar(), + ], + ); + } + + /// From → to, drawn as a journey rather than two labelled fields. + Widget _routeCard() { + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _routeEnd( + icon: LucideIcons.circleDot, + color: ColorConstants.primary, + label: 'FROM', + address: _s(_from['address']), + pincode: _s(_from['pincode']), + ), + // The connector. Reads as one journey, which two stacked address + // blocks do not. + Padding( + padding: EdgeInsets.only(left: 9.w), + child: SizedBox( + height: 22.h, + child: VerticalDivider( + width: 2.w, + thickness: 2, + color: ColorConstants.borderSubtle, + ), + ), + ), + _routeEnd( + icon: LucideIcons.mapPin, + color: ColorConstants.acceptGreen, + label: 'TO', + address: _s(_to['address']), + pincode: _s(_to['pincode']), + city: _s(_to['city']), + ), + ], + ), + ); + } + + Widget _routeEnd({ + required IconData icon, + required Color color, + required String label, + required String address, + required String pincode, + String city = '', + }) { + final tail = [ + if (city.isNotEmpty) city, + if (pincode.isNotEmpty) pincode, + ].join(' · '); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18.sp, color: color), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + address.isEmpty ? 'Not captured' : address, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, + height: 1.3, + color: address.isEmpty + ? ColorConstants.secondaryText + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + if (tail.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 2.h), + child: Text( + tail, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _shipmentCard() { + final weight = widget.shipment['weight']; + final km = widget.shipment['approxkm']; + + return AppCard( + child: Column( + children: [ + _row('Weight', weight == null ? '—' : '$weight kg'), + _divider(), + _row('Category', _s(widget.shipment['category'])), + _divider(), + _row('Service', _s(widget.shipment['servicetype'])), + if (_s(widget.shipment['zone']).isNotEmpty) ...[ + _divider(), + _row('Zone', _s(widget.shipment['zone'])), + ], + if (km is num) ...[ + _divider(), + _row( + 'Distance', + '≈ ${km.toStringAsFixed(1)} km', + // Said plainly: it is a straight line between two pins, and the + // fare comes from the zone. Letting the rider assume otherwise is + // how a customer gets told the wrong basis for a charge. + note: 'straight line — the fare uses the zone', + ), + ], + ], + ), + ); + } + + Widget _paymentCard() { + return AppCard( + child: Column( + children: [ + Row( + children: [ + for (final m in ShipmentPayMethod.values) ...[ + Expanded(child: _methodTile(m)), + if (m != ShipmentPayMethod.values.last) SizedBox(width: 8.w), + ], + ], + ), + SizedBox(height: 14.h), + Row( + children: [ + Expanded( + child: Text( + 'Amount to collect', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Text( + _amountLabel, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _methodTile(ShipmentPayMethod m) { + final selected = _method == m; + return GestureDetector( + onTap: _busy ? null : () => setState(() => _method = m), + child: AnimatedContainer( + duration: DesignConstants.motionState, + padding: EdgeInsets.symmetric(vertical: 12.h), + decoration: BoxDecoration( + color: selected + ? ColorConstants.primary.withValues(alpha: 0.08) + : ColorConstants.daylightSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all( + color: selected + ? ColorConstants.primary + : ColorConstants.borderSubtle, + width: selected ? 1.6 : 1, + ), + ), + child: Column( + children: [ + Icon( + m.icon, + size: 20.sp, + color: selected + ? ColorConstants.primary + : ColorConstants.secondaryText, + ), + SizedBox(height: 5.h), + Text( + m.label, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: selected + ? ColorConstants.primary + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + /// The commit. Names the amount, because that is the fact the rider is acting + /// on and the one he will be asked for at the door. + Widget _payBar() { + return Container( + padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 12.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + border: Border( + top: BorderSide(color: ColorConstants.borderSubtle, width: 1), + ), + boxShadow: DesignConstants.shadowSm, + ), + child: PrimaryButton( + label: _amount > 0 + ? 'Pay $_amountLabel' + // A prepaid or zero-rated shipment still has to be created; the + // button says what it does rather than asking for money nobody owes. + : 'Create order', + loading: _busy, + onPressed: _busy ? null : _confirm, + ), + ); + } + + Widget _row(String label, String value, {String? note}) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 7.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Text( + value.isEmpty ? '—' : value, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + if (note != null) + Padding( + padding: EdgeInsets.only(top: 2.h), + child: Text( + note, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + Widget _divider() => Divider(height: 1.h, color: ColorConstants.borderSubtle); + + // ── Success ──────────────────────────────────────────────────────────── + // + // One moment, three beats, staggered so they read as a sequence rather than + // a screen that swapped: the ring opens, the tick stamps down inside it, and + // the words arrive last. All of it drives off one controller, so the timing + // cannot drift. + + Widget _successView() { + final tracking = widget.trackingNo(); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + AnimatedBuilder( + animation: _successController, + builder: (_, _) => SizedBox( + width: 132.w, + height: 132.w, + child: Stack( + alignment: Alignment.center, + children: [ + // The ring, which expands and fades — a single ripple, not a + // loop. A repeating pulse would keep asking for attention on + // a screen whose job is finished. + Transform.scale( + scale: 0.6 + (_ringScale.value * 0.55), + child: Opacity( + opacity: (1 - _ringScale.value).clamp(0.0, 1.0) * 0.35, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorConstants.acceptGreen, + ), + ), + ), + ), + Transform.scale( + scale: _tickScale.value.clamp(0.0, 1.2), + child: Container( + width: 92.w, + height: 92.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorConstants.acceptGreen, + boxShadow: [ + BoxShadow( + color: ColorConstants.acceptGreen.withValues( + alpha: 0.28, + ), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon( + LucideIcons.check, + size: 46.sp, + color: ColorConstants.onAccent, + ), + ), + ), + ], + ), + ), + ), + SizedBox(height: 26.h), + FadeTransition( + opacity: _copyFade, + child: Column( + children: [ + Text( + 'Your order has been created', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + Text( + _amount > 0 + ? '$_amountLabel collected by ${_method.label}' + : 'Shipment created', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + // The receipt. Real data from `pickup-complete`, so a customer + // asking "what's my tracking number?" gets an answer on the + // doorstep instead of a phone call to the hub. + if (tracking.isNotEmpty) ...[ + SizedBox(height: 18.h), + Container( + padding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 10.h, + ), + decoration: BoxDecoration( + color: ColorConstants.daylightSurface, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + border: Border.all(color: ColorConstants.borderSubtle), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.truck, + size: 15.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 8.w), + Text( + tracking, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ], + ), + ), + const Spacer(), + FadeTransition( + opacity: _copyFade, + child: Padding( + padding: EdgeInsets.only(bottom: 16.h), + child: PrimaryButton( + label: 'Done', + onPressed: () => Navigator.of(context).pop({ + // The shape `map.dart` already reads, so the handoff screen, + // the compliance stamping and the next-stop list all keep + // working untouched. + 'outcome': 'completed', + 'isDelivery': false, + 'paymentMethod': _method.label, + 'amountCollected': _amount, + }), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/skip_sheet.dart b/lib/views/Dashboard/pickups/skip_sheet.dart index 677bc0e..3e09194 100644 --- a/lib/views/Dashboard/pickups/skip_sheet.dart +++ b/lib/views/Dashboard/pickups/skip_sheet.dart @@ -8,397 +8,360 @@ Future _showMyOptionsSheet( int selected = -1; bool isLoading = false; + // ── Why the list is this long, and no longer ── + // + // Two reasons was not a list, it was a shrug: every skip that was not a + // missed call came back to the office as "customer not at the location", + // which is the one thing operations cannot act on. These are the eight + // outcomes a rider actually reports, and each one implies a different next + // move for somebody — reschedule, re-address, call the client, refund. + // + // Eight is also the ceiling. A reason list a rider has to *read* at a gate is + // a reason list he will pick the first item from. final List skipReasons = [ 'Customer unreachable', 'Customer not at the location', + 'Wrong address', + 'Access problem', + 'Customer refused', + 'Delivery paused', + 'Quantity issue', + 'Other', ]; - await showModalBottomSheet( - context: context, - sheetAnimationStyle: kMilerSheetStyle, - isScrollControlled: true, - backgroundColor: Colors.transparent, + // ── Through the kit ── + // + // Three violations in one sheet, and each is the kind that only shows up on + // somebody else's phone: + // + // • its own `SafeArea(bottom: true)` **wrapping** the sheet, on top of the + // 20 of bottom padding inside it — the double-inset rule exists for + // exactly this, and it is the one thing the system calls non-negotiable; + // • a hand-rolled scaffold — its own `milerGlassSheet`, its own gutter, its + // own handle — which is four decisions re-made and one of them (`12` + // top padding, not the kit's) already drifting; + // • a close **✕**, the one affordance the system forbids. The handle and + // the barrier already dismiss; a third way is a control that has to be + // reached for with a thumb that is holding the phone. + await showMilerSheet( + context, builder: (context) { - return SafeArea( - top: false, - left: false, - right: false, - bottom: true, - child: StatefulBuilder( - builder: (context, setState) { - // The reason list is the same question Update Status asks, so it is - // the same widget — see [_ReasonRow]. It used to be two outlined - // white cards on a white sheet, each with its icon inside a second - // box, and the selected one swapped its border for a thicker - // coloured one. - Widget reasonRow(String title, int index, IconData icon) { - return _ReasonRow( - label: title, - icon: icon, - accent: ColorConstants.primary, - selected: selected == index, - // No rule either side of the filled row: a hairline across the - // tint turns the chosen answer back into a box. - showRule: - index > 0 && selected != index && selected != index - 1, - onTap: isLoading - ? null - : () => setState(() => selected = index), - ); - } + return StatefulBuilder( + builder: (context, setState) { + // The reason list is the same question Update Status asks, so it is + // the same widget — see [MilerSheetChoiceRow]. It used to be two outlined + // white cards on a white sheet, each with its icon inside a second + // box, and the selected one swapped its border for a thicker + // coloured one. + Widget reasonRow(String title, int index, IconData icon) { + return MilerSheetChoiceRow( + label: title, + icon: icon, + accent: ColorConstants.primary, + selected: selected == index, + // No rule either side of the filled row: a hairline across the + // tint turns the chosen answer back into a box. + showRule: index > 0 && selected != index && selected != index - 1, + onTap: isLoading ? null : () => setState(() => selected = index), + ); + } - Future handleConfirm() async { - if (selected == -1 || isLoading) return; + Future handleConfirm() async { + if (selected == -1 || isLoading) return; - final dc = Get.put(PickupsController(), permanent: true); - final skipStatus = await dc.checkSkipStatus(); - final int skipCount = skipStatus['count'] ?? 0; - bool applyPenalty = false; + final dc = Get.put(PickupsController(), permanent: true); + final skipStatus = await dc.checkSkipStatus(); + final int skipCount = skipStatus['count'] ?? 0; + bool applyPenalty = false; - if (skipCount >= 2) { - final bool? confirm = await showAppDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + if (skipCount >= 2) { + final bool? confirm = await showAppDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, ), - elevation: 0, - backgroundColor: ColorConstants.pureSurface, - child: Padding( - padding: EdgeInsets.all(24.r), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: EdgeInsets.all(16.r), - decoration: BoxDecoration( - color: ColorConstants.errorLight, - shape: BoxShape.circle, - ), - child: Icon( - Icons.warning_amber_rounded, - color: ColorConstants.errorRed, - size: 36.sp, + ), + elevation: 0, + backgroundColor: ColorConstants.pureSurface, + child: Padding( + padding: EdgeInsets.all(24.r), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: ColorConstants.errorLight, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.triangleAlert, + color: ColorConstants.errorRed, + size: 36.sp, + ), + ), + SizedBox(height: 20.h), + Text( + 'Skip Limit Reached', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.onSurface, + ), + ), + SizedBox(height: 10.h), + Text( + 'You have exceeded the limit of 2 skips within 3 hours.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15.sp, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.onSurfaceVariant, + height: 1.4, + ), + ), + SizedBox(height: 14.h), + Container( + padding: EdgeInsets.all(12.r), + // Fill, no outline — the same rule the sheets + // now follow. A tinted panel is already a panel. + decoration: BoxDecoration( + color: ColorConstants.warningContainer, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, ), ), - SizedBox(height: 20.h), - Text( - 'Skip Limit Reached', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.w700, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.onSurface, - ), - ), - SizedBox(height: 10.h), - Text( - 'You have exceeded the limit of 2 skips within 3 hours.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 15.sp, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.onSurfaceVariant, - height: 1.4, - ), - ), - SizedBox(height: 14.h), - Container( - padding: EdgeInsets.all(12.r), - // Fill, no outline — the same rule the sheets - // now follow. A tinted panel is already a panel. - decoration: BoxDecoration( - color: ColorConstants.warningContainer, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - color: ColorConstants.warning, - size: 18.sp, - ), - SizedBox(width: 8.w), - Expanded( - child: Text( - "Proceeding will forfeit your bonus points for this session.", - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.rewardAmber, - fontFamily: FontConstants.fontFamily, - height: 1.3, - ), - ), - ), - ], - ), - ), - SizedBox(height: 24.h), - Row( + child: Row( children: [ - Expanded( - child: MilerButton( - label: "Cancel", - onPressed: () => - Navigator.pop(context, false), - variant: MilerButtonVariant.outlined, - color: ColorConstants.onSurfaceVariant, - height: ButtonSizes.secondary, - ), + Icon( + LucideIcons.info, + color: ColorConstants.warning, + size: 18.sp, ), - SizedBox(width: 12.w), + SizedBox(width: 8.w), Expanded( - child: MilerButton( - label: "Confirm skip", - onPressed: () => - Navigator.pop(context, true), - color: ColorConstants.errorRed, - height: ButtonSizes.secondary, + child: Text( + "Proceeding will forfeit your bonus points for this session.", + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.rewardAmber, + fontFamily: FontConstants.fontFamily, + height: 1.3, + ), ), ), ], ), - ], - ), - ), - ); - }, - ); - - if (confirm != true) return; - applyPenalty = true; - } - - setState(() => isLoading = true); - - try { - final dc = Get.put(PickupsController(), permanent: true); - final d = pickup; - final int pickupId = int.tryParse('${d['pickupid'] ?? 0}') ?? 0; - final int orderHeaderId = - int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; - final String reason = - selected >= 0 && selected < skipReasons.length - ? skipReasons[selected] - : ''; - - if (pickupId > 0 && orderHeaderId > 0) { - String riderLatStr = '0'; - String riderLngStr = '0'; - try { - final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - timeLimit: const Duration(seconds: 5), - ); - riderLatStr = position.latitude.toStringAsFixed(6); - riderLngStr = position.longitude.toStringAsFixed(6); - } catch (e) { - debugPrint('[SKIP] Error getting rider location: $e'); - try { - final lastPos = await Geolocator.getLastKnownPosition(); - if (lastPos != null) { - riderLatStr = lastPos.latitude.toStringAsFixed(6); - riderLngStr = lastPos.longitude.toStringAsFixed(6); - } - } catch (_) {} - } - - debugPrint( - '[SKIP] Updating status for pickupId=$pickupId orderHeaderId=$orderHeaderId reason="$reason"', - ); - - final ok = await dc.updateSkippedStatus( - pickupId: pickupId, - orderHeaderId: orderHeaderId, - ridersLat: riderLatStr, - ridersLng: riderLngStr, - notes: reason, - ); - - debugPrint('[SKIP] Status update result: $ok'); - - // Optimistic skip — reflect it locally and move on even if the - // API fails (e.g. mock/offline data), so skip never appears - // unresponsive and the rider can proceed to the next stop. - await dc.registerSkip(applyPenalty: applyPenalty); - - // Recorded before the sheet closes, and regardless of which - // screen the skip was taken from: the map screen passes no - // `parentState`, so the branch below is the only thing that - // remembered a skip and it did so in memory, on one screen. - // Activity is where the rider now finds a skipped stop, and - // it reads this store. See [addSkippedBooking]. - await addSkippedBooking(d, reason: reason); - - if (context.mounted) { - Navigator.pop(context); - - if (parentState != null) { - parentState.markOrderAsSkipped(d, reason); - } else { - debugPrint( - '[SKIP] Skipped from Nav/Map screen, sheet closed.', - ); - } - } - } else { - debugPrint( - '[SKIP] Invalid pickupId: $pickupId or orderHeaderId: $orderHeaderId', - ); - if (context.mounted) { - AppFeedback.error(context, 'Invalid pickup information'); - } - } - } catch (e) { - debugPrint('[SKIP] Error updating skip status: $e'); - if (context.mounted) { - AppFeedback.error(context, 'Something went wrong — try again'); - } - } finally { - if (context.mounted) { - setState(() => isLoading = false); - } - } - } - - // Frosted, like every other sheet in the app — see - // [milerGlassSheet]. - return milerGlassSheet( - child: Padding( - padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Container( - width: 40.w, - height: 4.h, - decoration: BoxDecoration( - color: ColorConstants.borderSubtle, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - ), - ), - SizedBox(height: 16.h), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + SizedBox(height: 24.h), + Row( children: [ - Text( - 'Skip this pickup', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 19.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.slateText, + Expanded( + child: MilerButton( + label: "Cancel", + onPressed: () => + Navigator.pop(context, false), + variant: MilerButtonVariant.outlined, + color: ColorConstants.onSurfaceVariant, + height: ButtonSizes.secondary, ), ), - SizedBox(height: 2.h), - Text( - 'Tell us why so we can reassign it', - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w600, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.secondaryText, + SizedBox(width: 12.w), + Expanded( + child: MilerButton( + label: "Confirm skip", + onPressed: () => Navigator.pop(context, true), + color: ColorConstants.errorRed, + height: ButtonSizes.secondary, ), ), ], ), - ), - SizedBox(width: 10.w), - InkWell( - onTap: isLoading - ? null - : () => Navigator.pop(context), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - child: Container( - padding: EdgeInsets.all(8.r), - decoration: BoxDecoration( - color: ColorConstants.neutralLight, - shape: BoxShape.circle, - ), - child: Icon( - Icons.close_rounded, - color: ColorConstants.slateText, - size: 18.sp, - ), - ), - ), - ], - ), - SizedBox(height: 16.h), - // ── Which pickup ── - // - // The sheet asked the rider to skip "this pickup" and never - // said which one. He arrives here from a swipe on a list of - // near-identical rows, and skipping the wrong stop is not an - // action he can take back — it goes to the office as a - // refusal against a customer who was standing there waiting. - _StopBrief(pickup: pickup, showRule: true), - SizedBox(height: 8.h), - reasonRow( - 'Customer unreachable', - 0, - Icons.phone_missed_rounded, - ), - reasonRow( - 'Customer not at the location', - 1, - Icons.person_off_outlined, - ), - SizedBox(height: 20.h), - if (isLoading) - Container( - height: ButtonSizes.primary, - alignment: Alignment.center, - decoration: BoxDecoration( - // Matches the button it stands in for. - color: ColorConstants.errorRed, - borderRadius: BorderRadius.circular( - ButtonSizes.radius, - ), - ), - child: const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: Colors.white, - ), - ), - ) - else - // ── Skipping is destructive, and looks it ── - // - // This was `primary` while the penalty confirmation - // nested inside the same flow was `errorRed` — one action - // in two colours, two taps apart. Skipping tells the hub - // a customer was not served; it is the destructive branch - // of this screen and takes the destructive colour, the - // same one Reject uses on Home. - MilerButton( - label: 'Confirm skip', - icon: Icons.skip_next_rounded, - color: ColorConstants.errorRed, - onPressed: selected == -1 ? null : handleConfirm, + ], ), - ], - ), + ), + ); + }, + ); + + if (confirm != true) return; + applyPenalty = true; + } + + setState(() => isLoading = true); + + try { + final dc = Get.put(PickupsController(), permanent: true); + final d = pickup; + final int pickupId = int.tryParse('${d['pickupid'] ?? 0}') ?? 0; + final int orderHeaderId = + int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; + final String reason = + selected >= 0 && selected < skipReasons.length + ? skipReasons[selected] + : ''; + + if (pickupId > 0 && orderHeaderId > 0) { + String riderLatStr = '0'; + String riderLngStr = '0'; + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 5), + ); + riderLatStr = position.latitude.toStringAsFixed(6); + riderLngStr = position.longitude.toStringAsFixed(6); + } catch (e) { + debugPrint('[SKIP] Error getting rider location: $e'); + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + riderLatStr = lastPos.latitude.toStringAsFixed(6); + riderLngStr = lastPos.longitude.toStringAsFixed(6); + } + } catch (_) {} + } + + debugPrint( + '[SKIP] Updating status for pickupId=$pickupId orderHeaderId=$orderHeaderId reason="$reason"', + ); + + final ok = await dc.updateSkippedStatus( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + notes: reason, + ); + + debugPrint('[SKIP] Status update result: $ok'); + + // Optimistic skip — reflect it locally and move on even if the + // API fails (e.g. mock/offline data), so skip never appears + // unresponsive and the rider can proceed to the next stop. + await dc.registerSkip(applyPenalty: applyPenalty); + + // Recorded before the sheet closes, and regardless of which + // screen the skip was taken from: the map screen passes no + // `parentState`, so the branch below is the only thing that + // remembered a skip and it did so in memory, on one screen. + // Activity is where the rider now finds a skipped stop, and + // it reads this store. See [addSkippedBooking]. + await addSkippedBooking(d, reason: reason); + + if (context.mounted) { + Navigator.pop(context); + + if (parentState != null) { + parentState.markOrderAsSkipped(d, reason); + } else { + debugPrint( + '[SKIP] Skipped from Nav/Map screen, sheet closed.', + ); + } + } + } else { + debugPrint( + '[SKIP] Invalid pickupId: $pickupId or orderHeaderId: $orderHeaderId', + ); + if (context.mounted) { + AppFeedback.error(context, 'Invalid pickup information'); + } + } + } catch (e) { + debugPrint('[SKIP] Error updating skip status: $e'); + if (context.mounted) { + AppFeedback.error(context, 'Something went wrong — try again'); + } + } finally { + if (context.mounted) { + setState(() => isLoading = false); + } + } + } + + return MilerSheetScaffold( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // One header idiom. 18sp/w700 with a 13.5 subtitle, the + // same as every other sheet — this was 19/12.5 with its own + // colours, which is close enough to look like a mistake + // rather than a variant. + const MilerSheetHeader( + title: 'Skip this pickup', + subtitle: 'Tell us why, so the hub can reassign it.', + ), + SizedBox(height: 16.h), + // ── Which pickup ── + // + // The sheet asked the rider to skip "this pickup" and never + // said which one. He arrives here from a swipe on a list of + // near-identical rows, and skipping the wrong stop is not an + // action he can take back — it goes to the office as a + // refusal against a customer who was standing there waiting. + _StopBrief(pickup: pickup, showRule: true), + SizedBox(height: 8.h), + reasonRow('Customer unreachable', 0, LucideIcons.phoneMissed), + reasonRow( + 'Customer not at the location', + 1, + LucideIcons.userX, + ), + reasonRow('Wrong address', 2, LucideIcons.mapPinOff), + reasonRow('Access problem', 3, LucideIcons.lock), + reasonRow('Customer refused', 4, LucideIcons.ban), + reasonRow('Delivery paused', 5, LucideIcons.circlePause), + reasonRow('Quantity issue', 6, LucideIcons.package), + reasonRow('Other', 7, LucideIcons.ellipsis), + SizedBox(height: 20.h), + if (isLoading) + Container( + height: ButtonSizes.primary, + alignment: Alignment.center, + decoration: BoxDecoration( + // Matches the button it stands in for. + color: ColorConstants.errorRed, + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ), + ) + else + // ── Skipping is destructive, and looks it ── + // + // This was `primary` while the penalty confirmation + // nested inside the same flow was `errorRed` — one action + // in two colours, two taps apart. Skipping tells the hub + // a customer was not served; it is the destructive branch + // of this screen and takes the destructive colour, the + // same one Reject uses on Home. + MilerButton( + label: 'Confirm skip', + icon: LucideIcons.clock, + color: ColorConstants.errorRed, + onPressed: selected == -1 ? null : handleConfirm, + ), + ], ), - ); - }, - ), + ), + ); + }, ); }, ); diff --git a/lib/views/Dashboard/pickups/stop_type.dart b/lib/views/Dashboard/pickups/stop_type.dart index a46ba87..10d43f1 100644 --- a/lib/views/Dashboard/pickups/stop_type.dart +++ b/lib/views/Dashboard/pickups/stop_type.dart @@ -1,5 +1,7 @@ -import 'package:flutter/foundation.dart'; +import 'package:miler/data/milk_run.dart'; 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'; /// Mixed pickup + delivery routes (Amazon/Flipkart-style), including stops @@ -18,28 +20,35 @@ import 'package:miler/views/helpers/constants/Colorconstants.dart'; /// BACKEND DEPENDENCY: the app does not yet receive a stop `type` field. When /// the backend starts sending `type`/`stoptype` = `pickup` | `delivery` | /// `combined` (or sends both a delivery and a pickup quantity on one stop), -/// this helper picks it up automatically and the demo fallback is ignored. +/// this helper picks it up automatically. Until then every stop is a pickup. enum StopKind { pickup, delivery, combined } /// One unit of work inside a stop. A combined stop runs both, in order. enum StopLeg { delivery, pickup } -/// Demo/mock switch. While the backend has no stop-type field, this makes -/// stop 2 a delivery and stop 3 a combined stop so all three card types and -/// flows can be exercised on real data. -/// -/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds. Once the -/// backend sends `type`, [stopKindOf] prefers it anyway and this is ignored. -const bool kDemoMixedRoute = kDebugMode; - /// Resolve a stop's kind. /// /// Resolution order: /// 1. An explicit backend `type` / `stoptype` / `stopType` string. /// 2. Structural inference — a stop carrying BOTH a delivery quantity and a /// pickup quantity is a combined stop even if never labelled one. -/// 3. The demo pattern, in debug builds only. +/// 3. The line of work — every customer stop on a meal run is a drop. /// 4. Pickup — the app's original behaviour. +/// +/// There used to be another rule: a debug-only pattern that made every even +/// stop a delivery and every third one combined, so the three card types could +/// be seen without backend support. It is gone. A rule that invents what a stop +/// *is* means a debug build shows a different route from the release build +/// standing next to it, and the first question on any bug report — "is that +/// what the server actually said?" — could not be answered from the screen. +/// +/// Rule 3 is not that rule coming back. It does not invent anything: on a meal +/// run the *shape of the day* is the fact — the rider loads at a kitchen and +/// every stop after that is a subscriber's door. Falling through to "pickup" +/// there put the word **Picked up** on the confirm button at a customer's flat +/// and called his lunch a collection, for no better reason than that the +/// payload had not labelled a field. An explicit label still wins, so a meal +/// route that ever does carry a genuine collection is still read correctly. StopKind stopKindOf(Map item) { final raw = _rawType(item); @@ -62,18 +71,10 @@ StopKind stopKindOf(Map item) { return StopKind.combined; } - if (kDemoMixedRoute) { - final stepRaw = item['step'] ?? item['Step']; - final step = stepRaw is num - ? stepRaw.toInt() - : int.tryParse('${stepRaw ?? ''}') ?? 0; - // Stop 3, 6, 9… → combined; other even stops → delivery. A demo route - // therefore shows all three card types. - if (step > 0) { - if (step % 3 == 0) return StopKind.combined; - if (step.isEven) return StopKind.delivery; - } - } + // The kitchen is a source, not a stop. Everything in a meal rider's route + // list is somewhere he is handing food over. + if (ServiceProfile.active.sourceIsKitchen) return StopKind.delivery; + return StopKind.pickup; } @@ -122,7 +123,19 @@ int pickupParcelCount(Map item) { /// Cash still to be collected at this stop, in rupees. Deliveries are prepaid, /// so this only ever applies to a pickup leg. +/// +/// ── The one gate for money, app-wide ── +/// +/// A tenant whose customers pay by monthly subscription never collects at the +/// door, and this is where that is enforced rather than at each screen. Every +/// place that asks about money — the confirm sheet's `_hasCollection`, the +/// payment page's launch condition on Home, the amount printed on a stop card — +/// already routes through here and already handles zero, so one `if` retires +/// the whole cash flow for a service tenant instead of a branch per screen. +/// See [ServiceProfile.collectsCash]. double stopCollectionAmount(Map item) { + if (!ServiceProfile.active.collectsCash) return 0; + final v = item['collectionamt'] ?? item['pickupamt'] ?? item['codamount']; if (v == null) return 0; if (v is num) return v.toDouble(); @@ -130,6 +143,46 @@ double stopCollectionAmount(Map item) { return double.tryParse(cleaned) ?? 0; } +/// Where this stop's parcel came from — the kitchen, on a meal run. +/// +/// Empty on a Doormile stop, which has no source worth naming: everything comes +/// from the hub the rider started at. On a service route it is the one fact +/// that tells two identical-looking bags apart when he is carrying loads from +/// two kitchens at once, so it is shown on the card rather than in a detail +/// sheet he will not open at a doorstep. +/// Delegates to [MilkRun.sourceNameOf] — the one reader. +/// +/// This used to carry its own key list, and the two drifted: a payload with +/// only the CamelCase key had a source here and none there, so the route card +/// grouped a stop under a counter it then could not name and titled the group +/// "pickup". See [MilkRun.sourceNameOf]. +String stopSourceName(Map item) => MilkRun.sourceNameOf(item); + +/// Stable id for [stopSourceName], used to group and to gate. +/// +/// The name is what the rider reads; this is what the app compares. Two +/// kitchens can share a display name across areas, and P3's rule — a drop stays +/// locked until *its* kitchen is collected — has to key off something the hub +/// controls. +String stopSourceId(Map item) => + (item['kitchenid'] ?? item['KitchenId'] ?? item['sourceid'] ?? '') + .toString() + .trim(); + +/// The label physically on the bag, matched at the kitchen and read at the door. +/// +/// Ten near-identical lunch bags is where mis-delivery comes from, and no OTP or +/// photo catches it — the rider is confident, he is just at the wrong door with +/// the wrong bag. A printed label on the card is the cheapest thing that does. +String stopBagLabel(Map item) => + (item['baglabel'] ?? + item['BagLabel'] ?? + item['bagid'] ?? + item['labelcode'] ?? + '') + .toString() + .trim(); + /// The delivery OTP, when the stop has a delivery leg. String stopOtp(Map item) => (item['otp'] ?? item['Otp'] ?? item['deliveryotp'] ?? '').toString().trim(); @@ -181,24 +234,35 @@ extension StopKindUi on StopKind { /// A darkened [accent] — see the note on [ColorConstants.pickupChip]. Use this /// for the tag and nothing else: the chip is the one place stop type is /// allowed to be a colour, because it is a mark rather than a surface. - Color get chipColor => switch (this) { - StopKind.pickup => ColorConstants.pickupChip, - StopKind.delivery => ColorConstants.deliveryChip, - StopKind.combined => ColorConstants.combinedChip, - }; + /// + /// One colour for the whole day on a service tenant, for the same reason its + /// label is one word — see [chipLabel]. + Color get chipColor => ServiceProfile.active.usesSingleWorkType + ? ColorConstants.serviceChip + : switch (this) { + StopKind.pickup => ColorConstants.pickupChip, + StopKind.delivery => ColorConstants.deliveryChip, + StopKind.combined => ColorConstants.combinedChip, + }; - IconData get icon => switch (this) { - StopKind.pickup => Icons.inventory_2_rounded, - StopKind.delivery => Icons.local_shipping_rounded, - StopKind.combined => Icons.swap_horiz_rounded, - }; + IconData get icon => ServiceProfile.active.usesSingleWorkType + // A meal, not a parcel or a lorry: what a stop on this line has in + // common across the day is that somebody is being handed food. + ? LucideIcons.utensils + : switch (this) { + StopKind.pickup => LucideIcons.package, + StopKind.delivery => LucideIcons.truck, + StopKind.combined => LucideIcons.arrowLeftRight, + }; /// Short badge label. - String get badgeLabel => switch (this) { - StopKind.pickup => 'PICKUP', - StopKind.delivery => 'DELIVERY', - StopKind.combined => 'PICKUP & DELIVERY', - }; + String get badgeLabel => + ServiceProfile.active.workTypeLabel ?? + switch (this) { + StopKind.pickup => 'PICKUP', + StopKind.delivery => 'DELIVERY', + StopKind.combined => 'PICKUP & DELIVERY', + }; /// Sentence-case name of the mode, for places that state it as a *label* /// rather than stamp it as a chip — see the handoff screen's next-stop block. @@ -207,11 +271,19 @@ extension StopKindUi on StopKind { /// of other things. When the type is a quiet line under a hero heading there /// is nothing to compete with, and all-caps at that size reads as a second /// heading rather than as an attribute. - String get titleLabel => switch (this) { - StopKind.pickup => 'Pickup', - StopKind.delivery => 'Delivery', - StopKind.combined => 'Pickup & Delivery', - }; + String get titleLabel { + final one = ServiceProfile.active.workTypeLabel; + if (one != null) { + // Sentence case, for the same reason the parcel labels are: all-caps at + // this size reads as a second heading. + return one[0] + one.substring(1).toLowerCase(); + } + return switch (this) { + StopKind.pickup => 'Pickup', + StopKind.delivery => 'Delivery', + StopKind.combined => 'Pickup & Delivery', + }; + } /// Label for the small chip on a stop card — **both** stop cards. /// @@ -229,11 +301,17 @@ extension StopKindUi on StopKind { /// stop cards finally agree. The full wording survives where there is room /// for it — the detail sheet and Activity's Type row — which is what /// [badgeLabel] is still for. - String get chipLabel => switch (this) { - StopKind.pickup => 'PICKUP', - StopKind.delivery => 'DELIVERY', - StopKind.combined => 'P&D', - }; + /// One label all day on a service tenant, and it is the shorter of the two + /// reasons this getter is profile-aware: the other is that a rider whose + /// morning says PICKUP and whose afternoon says DELIVERY will go looking for + /// a difference in how he handles the bag, and there isn't one. + String get chipLabel => + ServiceProfile.active.workTypeLabel ?? + switch (this) { + StopKind.pickup => 'PICKUP', + StopKind.delivery => 'DELIVERY', + StopKind.combined => 'P&D', + }; /// Location-row label. String get locationLabel => switch (this) { @@ -301,8 +379,7 @@ extension StopLegUi on StopLeg { String get proofHint => isDelivery ? 'OTP + photo required' : 'Photo + weight required'; - IconData get icon => - isDelivery ? Icons.local_shipping_rounded : Icons.inventory_2_rounded; + IconData get icon => isDelivery ? LucideIcons.truck : LucideIcons.package; Color get accent => isDelivery ? ColorConstants.deliveryAccent : ColorConstants.pickupAccent; diff --git a/lib/views/Dashboard/pickups/stop_verify.dart b/lib/views/Dashboard/pickups/stop_verify.dart index aa1c169..e6cdcba 100644 --- a/lib/views/Dashboard/pickups/stop_verify.dart +++ b/lib/views/Dashboard/pickups/stop_verify.dart @@ -1,13 +1,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; @@ -72,10 +75,10 @@ extension _RecipientUi on _Recipient { }; IconData get icon => switch (this) { - _Recipient.customer => Icons.person_rounded, - _Recipient.family => Icons.family_restroom_rounded, - _Recipient.security => Icons.shield_rounded, - _Recipient.neighbour => Icons.home_rounded, + _Recipient.customer => LucideIcons.user, + _Recipient.family => LucideIcons.users, + _Recipient.security => LucideIcons.shieldCheck, + _Recipient.neighbour => LucideIcons.house, }; /// A third party took it → we need their name on record. @@ -96,9 +99,12 @@ extension _ConditionUi on _Condition { }; IconData get icon => switch (this) { - _Condition.sealed => Icons.verified_rounded, - _Condition.unpacked => Icons.inventory_rounded, - _Condition.damaged => Icons.report_problem_rounded, + _Condition.sealed => LucideIcons.badgeCheck, + // An open carton, not a stack of them: `boxes` drew three small squares + // that read as a cluster of *something* at 16sp, which is the one thing a + // condition glyph must not do. `packageOpen` says unsealed at a glance. + _Condition.unpacked => LucideIcons.packageOpen, + _Condition.damaged => LucideIcons.triangleAlert, }; Color get accent => switch (this) { @@ -125,9 +131,27 @@ class _StopVerificationPageState extends State { final ImagePicker _picker = ImagePicker(); - /// Current step. `_legs.length` is the review step, so it is always last. + /// Current step. When [_hasReview] the index `_legs.length` is the review + /// step, so it is always last. int _step = 0; + /// ── Whether this page ends with its own review ── + /// + /// It used to, always. On a **logistics** stop that is now one review too + /// many: the shipment desk opens straight after this page and is followed by + /// a review of its own that covers the whole order — parcel, addresses, + /// weight, price and payment on one screen. Asking the rider to confirm the + /// parcel, then describe the shipment, then confirm it all again is three + /// screens for one doorstep and two of them look alike. + /// + /// So the page's own review is dropped exactly where a later one subsumes + /// it, and kept where nothing follows — the line that verifies but does not + /// price still needs a last look before it commits. + bool get _hasReview => !ServiceProfile.active.capturesShipmentAddresses; + + /// The index of the last step the rider can be on. + int get _lastStep => _hasReview ? _legs.length : _legs.length - 1; + // ── Delivery leg ── final Set _handedOver = {}; _Recipient _recipient = _Recipient.customer; @@ -153,8 +177,30 @@ class _StopVerificationPageState extends State { _legs = _kind.legs; _customerName = (d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer') .toString(); - _address = (d['pickupaddress'] ?? d['PickupAddress'] ?? 'Stop location') - .toString(); + // ── The address of the door he is at, not the one he came from ── + // + // This read `pickupaddress` on every leg. On a delivery that is the + // *sender's* address — where the parcel was collected — so the one line + // naming the place the rider is standing was either wrong or, on a + // delivery-only stop that carries no pickup address, the placeholder + // "Stop location". A verification screen whose subject is a doorstep must + // name that doorstep. + String field(List keys) { + for (final k in keys) { + final v = (d[k] ?? '').toString().trim(); + if (v.isNotEmpty && v != 'null') return v; + } + return ''; + } + + final drop = field(['dropaddress', 'DropAddress', 'deliveryaddress']); + final collect = field(['pickupaddress', 'PickupAddress']); + _address = switch (_kind) { + // A combined stop is one door: whichever address the row actually + // carries describes it. + StopKind.delivery => drop.isNotEmpty ? drop : collect, + _ => collect.isNotEmpty ? collect : drop, + }; _cash = stopCollectionAmount(d); // A leg that exists always has at least one parcel — a stop that hands @@ -227,7 +273,7 @@ class _StopVerificationPageState extends State { /// What is missing on the step being shown, phrased as the next thing to do. String? get _blockingHint { - if (_step >= _legs.length) { + if (_hasReview && _step >= _legs.length) { if (_allDone) return null; // Name the leg rather than saying "a step above" — on a combined stop // that is the difference between one tap back and two. @@ -293,7 +339,6 @@ class _StopVerificationPageState extends State { /// See [AppFeedback]. void _toast(String message) => AppFeedback.error(context, message); - void _next() { final hint = _blockingHint; if (hint != null) { @@ -301,7 +346,13 @@ class _StopVerificationPageState extends State { return; } FocusScope.of(context).unfocus(); - setState(() => _step = (_step + 1).clamp(0, _legs.length)); + // Without a review step the last leg IS the commit, so advancing past it + // confirms rather than walking onto a screen that no longer exists. + if (!_hasReview && _step >= _legs.length - 1) { + _confirm(); + return; + } + setState(() => _step = (_step + 1).clamp(0, _lastStep)); } void _back() { @@ -416,13 +467,12 @@ class _StopVerificationPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _stopSummary(), - if (_step >= _legs.length) ...[ - SizedBox(height: 22.h), - Container( - height: 1, - color: ColorConstants.borderSubtle, - ), - SizedBox(height: 20.h), + // No rule under the summary any more: the steps below + // are panels with their own edges, and a divider between a + // card and a card is a line drawn between two boundaries + // that already exist. + if (_hasReview && _step >= _legs.length) ...[ + SizedBox(height: 6.h), _reviewStep(), ] else if (_legs[_step].isDelivery) _deliveryStep() @@ -533,7 +583,7 @@ class _StopVerificationPageState extends State { ), child: past ? Icon( - Icons.check_rounded, + LucideIcons.check, size: 15.sp, color: Colors.white, ) @@ -541,7 +591,7 @@ class _StopVerificationPageState extends State { '${idx + 1}', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: current ? Colors.white : ColorConstants.secondaryText, @@ -561,7 +611,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 10.5.sp, height: 1.2, - fontWeight: current ? FontWeight.w800 : FontWeight.w600, + fontWeight: current ? FontWeight.w700 : FontWeight.w500, color: past ? ColorConstants.acceptGreen : current @@ -622,7 +672,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: _kind.accent, fontFamily: FontConstants.fontFamily, ), @@ -638,7 +688,7 @@ class _StopVerificationPageState extends State { textAlign: TextAlign.right, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), @@ -653,24 +703,40 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.5, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), - SizedBox(height: 3.h), - Text( - _address, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - height: 1.4, - fontWeight: FontWeight.w500, - color: ColorConstants.onSurfaceVariant, - fontFamily: FontConstants.fontFamily, - ), + SizedBox(height: 5.h), + // The pin is doing work, not decorating: this line and the customer + // name above it are both grey-on-white running text at a glance, and + // the glyph is what says which of the two is the place. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: 2.h, right: 6.w), + child: Icon( + LucideIcons.mapPin, + size: 14.sp, + color: ColorConstants.secondaryText, + ), + ), + Expanded( + child: Text( + _address, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 13.sp, + height: 1.4, + color: ColorConstants.onSurfaceVariant, + ), + ), + ), + ], ), SizedBox(height: 12.h), // Wrap rather than Row: on a combined stop the two pills together are @@ -682,13 +748,13 @@ class _StopVerificationPageState extends State { children: [ if (_expectedDelivery > 0) _loadPill( - Icons.local_shipping_rounded, + LucideIcons.truck, 'Hand over $_expectedDelivery', ColorConstants.deliveryAccent, ), if (_expectedPickup > 0) _loadPill( - Icons.inventory_2_rounded, + LucideIcons.package, 'Collect $_expectedPickup', ColorConstants.pickupAccent, ), @@ -717,7 +783,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: accent, fontFamily: FontConstants.fontFamily, ), @@ -744,6 +810,7 @@ class _StopVerificationPageState extends State { '${_handedOver.length} of $_expectedDelivery ticked' : 'Tick it as it leaves your hands', done: _handedOver.length == _expectedDelivery, + icon: LucideIcons.package, child: Column( children: [ for (var i = 0; i < _expectedDelivery; i++) ...[ @@ -759,6 +826,7 @@ class _StopVerificationPageState extends State { ? 'A third party took it — record their name' : null, done: !_recipient.needsName || _recipientName.text.trim().length >= 2, + icon: LucideIcons.mapPinned, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -783,7 +851,7 @@ class _StopVerificationPageState extends State { _field( controller: _recipientName, hint: "Person's name", - icon: Icons.badge_rounded, + icon: LucideIcons.idCard, capitalization: TextCapitalization.words, ), ], @@ -794,6 +862,7 @@ class _StopVerificationPageState extends State { 'Delivery OTP', 'Ask the customer to read it out', done: _deliveryOtp.text.trim().length >= 4, + icon: LucideIcons.grid3x3, child: _CodeBoxes( controller: _deliveryOtp, length: 6, @@ -805,6 +874,7 @@ class _StopVerificationPageState extends State { 'Proof photo', 'The parcel at the door, or with the person who took it', done: _deliveryPhoto != null, + icon: LucideIcons.camera, child: _photoBox( file: _deliveryPhoto, accent: ColorConstants.deliveryAccent, @@ -843,9 +913,7 @@ class _StopVerificationPageState extends State { child: Row( children: [ Icon( - ticked - ? Icons.check_circle_rounded - : Icons.radio_button_unchecked_rounded, + ticked ? LucideIcons.circleCheck : LucideIcons.circle, size: 21.sp, color: ticked ? ColorConstants.acceptGreen @@ -855,24 +923,36 @@ class _StopVerificationPageState extends State { Expanded( child: Text( 'Parcel ${index + 1} of $_expectedDelivery', + maxLines: 2, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), - if (ticked) - Text( - 'Handed over', - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w800, - color: ColorConstants.acceptGreen, - fontFamily: FontConstants.fontFamily, + // Flexible, and after an `Expanded` that has already taken the + // slack: a fixed trailing label plus a 21sp glyph is wider than + // the tile at 2x text, and the tile is inside a panel now, which + // is 32pt narrower than the page it used to sit on. + if (ticked) ...[ + SizedBox(width: 8.w), + Flexible( + child: Text( + 'Handed over', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), ), ), + ], ], ), ), @@ -889,6 +969,7 @@ class _StopVerificationPageState extends State { 'Parcels collected', 'Count what you actually took', done: true, + icon: LucideIcons.shoppingCart, child: Column( children: [ _counter(), @@ -909,6 +990,7 @@ class _StopVerificationPageState extends State { 'Condition', 'Recorded now — it cannot be proved later', done: !_condition.needsNote, + icon: LucideIcons.badgeCheck, child: _choiceGrid( count: _Condition.values.length, chips: (width, lastWidth) => [ @@ -929,12 +1011,13 @@ class _StopVerificationPageState extends State { 'What happened?', 'Required — this is what settles a dispute later', done: _conditionNote.text.trim().length >= 3, + icon: LucideIcons.triangleAlert, child: _field( controller: _conditionNote, hint: _collected != _expectedPickup ? 'e.g. customer only had 2 ready' : 'e.g. corner of the box is crushed', - icon: Icons.notes_rounded, + icon: LucideIcons.stickyNote, maxLines: 3, capitalization: TextCapitalization.sentences, ), @@ -944,10 +1027,11 @@ class _StopVerificationPageState extends State { 'Weight', 'The consignment is billed on this — it cannot be established later', done: _weightEntered, + icon: LucideIcons.weight, child: _field( controller: _weight, hint: 'Total weight in kg', - icon: Icons.scale_rounded, + icon: LucideIcons.weight, keyboard: const TextInputType.numberWithOptions(decimal: true), formatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))], ), @@ -956,6 +1040,7 @@ class _StopVerificationPageState extends State { 'Parcel photo', 'All $_collected ${_collected == 1 ? 'parcel' : 'parcels'} in one shot', done: _pickupPhoto != null, + icon: LucideIcons.camera, child: _photoBox( file: _pickupPhoto, accent: ColorConstants.pickupAccent, @@ -969,6 +1054,7 @@ class _StopVerificationPageState extends State { ? 'Optional — the delivery OTP already verified this door' : 'Ask the customer to read it out', done: _kind.isCombined || _pickupCode.text.trim().length >= 4, + icon: LucideIcons.grid3x3, optional: _kind.isCombined, child: _CodeBoxes( controller: _pickupCode, @@ -984,6 +1070,16 @@ class _StopVerificationPageState extends State { Widget _counter() { Widget btn(IconData icon, VoidCallback? onTap) { final enabled = onTap != null; + // ── The count is the subject; these two only adjust it ── + // + // Both buttons wore the leg accent — which on a pickup *is* the brand + // red — as fill, rim and glyph. Two saturated brand-red controls framing + // the figure meant the loudest thing in the panel was the pair of + // nudges, not the number they nudge. + // + // Neutral chrome hands that back: the figure is the only ink with weight + // in the row, and the red the screen does spend is left for the leg + // itself and for the action bar that commits it. return PressScale( onTap: onTap, child: Container( @@ -991,12 +1087,12 @@ class _StopVerificationPageState extends State { height: 46.w, decoration: BoxDecoration( color: enabled - ? ColorConstants.pickupAccent.withValues(alpha: 0.10) + ? ColorConstants.pureSurface : ColorConstants.neutralLight, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( color: enabled - ? ColorConstants.pickupAccent.withValues(alpha: 0.35) + ? ColorConstants.borderStrong : ColorConstants.borderSubtle, width: 1.5, ), @@ -1005,7 +1101,7 @@ class _StopVerificationPageState extends State { icon, size: 21.sp, color: enabled - ? ColorConstants.pickupAccent + ? ColorConstants.slateText : ColorConstants.disabledFill, ), ), @@ -1015,7 +1111,7 @@ class _StopVerificationPageState extends State { return Row( children: [ btn( - Icons.remove_rounded, + LucideIcons.minus, _collected > 0 ? () => setState(() => _collected -= 1) : null, ), Expanded( @@ -1025,7 +1121,7 @@ class _StopVerificationPageState extends State { '$_collected', style: TextStyle( fontSize: 30.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -1, height: 1.1, color: ColorConstants.slateText, @@ -1036,7 +1132,7 @@ class _StopVerificationPageState extends State { 'of $_expectedPickup booked', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1045,7 +1141,7 @@ class _StopVerificationPageState extends State { ), ), btn( - Icons.add_rounded, + LucideIcons.plus, _collected < 99 ? () => setState(() => _collected += 1) : null, ), ], @@ -1082,8 +1178,8 @@ class _StopVerificationPageState extends State { children: [ Icon( _allDone - ? Icons.fact_check_rounded - : Icons.error_outline_rounded, + ? LucideIcons.clipboardCheck + : LucideIcons.circleAlert, size: 22.sp, color: _allDone ? ColorConstants.acceptGreen @@ -1097,7 +1193,7 @@ class _StopVerificationPageState extends State { : 'Something is missing', style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.5, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1115,7 +1211,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 13.5.sp, height: 1.4, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1129,19 +1225,19 @@ class _StopVerificationPageState extends State { _reviewLeg( title: 'Handed over', accent: ColorConstants.deliveryAccent, - icon: Icons.local_shipping_rounded, + icon: LucideIcons.truck, photo: _deliveryPhoto, done: _deliveryDone, facts: [ ReviewFact( 'Parcels', '${_handedOver.length} of $_expectedDelivery', - icon: Icons.inventory_rounded, + icon: LucideIcons.boxes, ), ReviewFact( 'OTP', _deliveryOtp.text.trim(), - icon: Icons.lock_outline_rounded, + icon: LucideIcons.lock, mono: true, ), ReviewFact( @@ -1161,19 +1257,19 @@ class _StopVerificationPageState extends State { _reviewLeg( title: 'Collected', accent: ColorConstants.pickupAccent, - icon: Icons.inventory_2_rounded, + icon: LucideIcons.package, photo: _pickupPhoto, done: _pickupDone, facts: [ ReviewFact( 'Parcels', '$_collected of $_expectedPickup', - icon: Icons.inventory_2_rounded, + icon: LucideIcons.package, ), ReviewFact( 'Weight', '${_weight.text.trim()} kg', - icon: Icons.scale_rounded, + icon: LucideIcons.weight, ), ReviewFact( 'Condition', @@ -1185,14 +1281,14 @@ class _StopVerificationPageState extends State { ReviewFact( 'Pickup code', _pickupCode.text.trim(), - icon: Icons.pin_rounded, + icon: LucideIcons.pin, mono: true, ), if (_conditionNote.text.trim().isNotEmpty) ReviewFact( 'Note', _conditionNote.text.trim(), - icon: Icons.notes_rounded, + icon: LucideIcons.stickyNote, tint: ColorConstants.warning, wide: true, ), @@ -1251,7 +1347,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.8, color: accent, fontFamily: FontConstants.fontFamily, @@ -1286,7 +1382,7 @@ class _StopVerificationPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Icon( - done ? Icons.check_circle_rounded : Icons.error_outline_rounded, + done ? LucideIcons.circleCheck : LucideIcons.circleAlert, size: 12.sp, color: color, ), @@ -1295,7 +1391,7 @@ class _StopVerificationPageState extends State { done ? 'Complete' : 'Unfinished', style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.2, color: color, fontFamily: FontConstants.fontFamily, @@ -1335,7 +1431,7 @@ class _StopVerificationPageState extends State { child: Row( children: [ Icon( - Icons.no_photography_rounded, + LucideIcons.imageOff, size: 15.sp, color: ColorConstants.warning, ), @@ -1345,7 +1441,7 @@ class _StopVerificationPageState extends State { 'No proof photo — go back and take one', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), @@ -1372,7 +1468,7 @@ class _StopVerificationPageState extends State { color: ColorConstants.pureSurface, alignment: Alignment.center, child: Icon( - Icons.broken_image_rounded, + LucideIcons.imageOff, size: 22.sp, color: ColorConstants.secondaryText, ), @@ -1394,17 +1490,13 @@ class _StopVerificationPageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.zoom_in_rounded, - size: 13.sp, - color: Colors.white, - ), + Icon(LucideIcons.zoomIn, size: 13.sp, color: Colors.white), SizedBox(width: 5.w), Text( 'Tap to enlarge', style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -1439,11 +1531,7 @@ class _StopVerificationPageState extends State { child: SafeArea( child: IconButton( tooltip: 'Close', - icon: Icon( - Icons.close_rounded, - size: 26.sp, - color: Colors.white, - ), + icon: Icon(LucideIcons.x, size: 26.sp, color: Colors.white), onPressed: () => Navigator.of(ctx).pop(), ), ), @@ -1533,7 +1621,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1549,7 +1637,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 15.sp, height: 1.25, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: fact.mono ? 1.2 : -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1567,7 +1655,7 @@ class _StopVerificationPageState extends State { // final commit into a reflex the rider stops noticing. Widget _bottomBar() { final hint = _blockingHint; - final onReview = _step >= _legs.length; + final onReview = _hasReview && _step >= _legs.length; return Container( padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 12.h), @@ -1588,7 +1676,7 @@ class _StopVerificationPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.info_outline_rounded, + LucideIcons.info, size: 14.sp, color: ColorConstants.secondaryText, ), @@ -1599,7 +1687,7 @@ class _StopVerificationPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1614,23 +1702,15 @@ class _StopVerificationPageState extends State { else if (hint != null) // Blocked. See [_blockedCta] — the bar goes colourless, and the // line above it says what is missing. - _blockedCta( - _step == _legs.length - 1 - ? 'Review' - : 'Continue to ' - '${_legs[_step + 1].isDelivery ? 'hand over' : 'collect'}', - ) + _blockedCta(_forwardLabel) else MilerButton( // "Next · collect" reads as a step number with a word attached. // "Continue to collect" is the sentence the rider is already // half-way through, and on a combined stop it is the only thing // on screen saying a second job follows this one. - label: _step == _legs.length - 1 - ? 'Review' - : 'Continue to ' - '${_legs[_step + 1].isDelivery ? 'hand over' : 'collect'}', - icon: Icons.arrow_forward_rounded, + label: _forwardLabel, + icon: LucideIcons.arrowRight, // ── Green, on every step ── // // This took the next leg's accent, so the forward button was @@ -1648,6 +1728,21 @@ class _StopVerificationPageState extends State { ); } + /// What the forward button is called on the step being shown. + /// + /// On the last leg it names the screen that actually follows: this page's own + /// review where it has one, and the shipment desk where the logistics flow + /// takes over. A button reading "Review" that opens a form asking for + /// addresses is the kind of small lie that makes a rider distrust the rest of + /// the flow. + String get _forwardLabel { + if (_step == _legs.length - 1) { + return _hasReview ? 'Review' : 'Continue to shipment'; + } + return 'Continue to ' + '${_legs[_step + 1].isDelivery ? 'hand over' : 'collect'}'; + } + /// The action bar when the step is not finished. /// /// ── Why not just a disabled [MilerButton] ── @@ -1675,7 +1770,7 @@ class _StopVerificationPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.lock_outline_rounded, + LucideIcons.lock, size: 17.sp, color: ColorConstants.secondaryText, ), @@ -1687,7 +1782,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1737,7 +1832,7 @@ class _StopVerificationPageState extends State { // the deliberation. So it is a button, labelled with what it does. return MilerButton( label: 'Confirm ${_kind.noun.toLowerCase()}', - icon: Icons.check_circle_rounded, + icon: LucideIcons.circleCheck, color: ColorConstants.acceptGreen, onPressed: _confirm, ); @@ -1768,55 +1863,125 @@ class _StopVerificationPageState extends State { /// /// The rule is drawn *above* each section, so the first one also separates /// the form from the stop summary heading it sits under. + /// One step of the form, as a surface of its own. + /// + /// ── Why these are panels and not ruled paragraphs ── + /// + /// They were an eyebrow, a hairline and a child, stacked down a white page. + /// That reads beautifully on a desk and badly at a gate: five steps with no + /// edges are one long form, the rider's place in it is carried entirely by a + /// 1px rule, and a section he has finished looks exactly like one he has not + /// except for an 18px tick at the far right of the line. + /// + /// A panel per step fixes all three at once. Each has a boundary, so the + /// screen is a short list of things to do rather than a scroll; each carries + /// its own state in its **fill** — the one property visible from arm's + /// length — so finished work recedes to a green wash and the step in front of + /// him keeps the page's only accent; and each is titled at a size meant to be + /// read outdoors instead of at eyebrow scale. + /// + /// The leading glyph is not decoration. Four of these steps are questions + /// with near-identical shapes — tick things, pick one of four, type a name, + /// take a photo — and the icon is what tells them apart before the title is + /// read. + /// The colour of the leg being verified. Falls back to the brand on the + /// review step, which belongs to no single leg. + Color get _legAccent => + _step < _legs.length ? _legs[_step].accent : ColorConstants.primary; + Widget _section( String title, String? subtitle, { required bool done, required Widget child, bool optional = false, + IconData icon = LucideIcons.circleCheck, }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 22.h), - Container(height: 1, color: ColorConstants.borderSubtle), - SizedBox(height: 20.h), - Row( + // The leg's colour, not the brand's. Each leg of this flow is colour-coded + // — a pickup is red, a delivery is blue — and the panels were painting + // their glyphs brand-red on a page whose every other accent was blue, + // which reads as two unrelated things on one screen. + final accent = done ? ColorConstants.acceptGreen : _legAccent; + + return Padding( + padding: EdgeInsets.only(top: 14.h), + child: AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOut, + width: double.infinity, + padding: EdgeInsets.fromLTRB(16.w, 15.h, 16.w, 16.h), + decoration: BoxDecoration( + // Done recedes; outstanding keeps the page. Neither is a border + // colour — a filled state survives sunlight and a 1px edge does not. + color: done + ? ColorConstants.acceptGreen.withValues(alpha: 0.055) + : ColorConstants.daylightSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all( + color: done + ? ColorConstants.acceptGreen.withValues(alpha: 0.22) + : ColorConstants.borderSubtle, + width: 1.2, + ), + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - title.toUpperCase(), - style: TextStyle( - fontSize: 11.sp, - height: 1.3, - fontWeight: FontWeight.w800, - letterSpacing: 1.2, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 30.w, + height: 30.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.11), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Icon( + done ? LucideIcons.check : icon, + size: 16.sp, + color: accent, + ), ), - ), + SizedBox(width: 11.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: MilerType.body.copyWith( + fontSize: 15.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.25, + ), + ), + if (subtitle != null) ...[ + SizedBox(height: 3.h), + Text( + subtitle, + style: MilerType.micro.copyWith(height: 1.35), + ), + ], + ], + ), + ), + SizedBox(width: 10.w), + Padding( + padding: EdgeInsets.only(top: 4.h), + child: _sectionMarker(done: done, optional: optional), + ), + ], ), - SizedBox(width: 10.w), - _sectionMarker(done: done, optional: optional), + SizedBox(height: 13.h), + child, ], ), - if (subtitle != null) ...[ - SizedBox(height: 5.h), - Text( - subtitle, - style: TextStyle( - fontSize: 13.sp, - height: 1.35, - fontWeight: FontWeight.w500, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - SizedBox(height: 13.h), - child, - ], + ), ); } @@ -1832,31 +1997,39 @@ class _StopVerificationPageState extends State { 'Optional', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ); } - if (done) { - return Icon( - Icons.check_circle_rounded, - size: 18.sp, - color: ColorConstants.acceptGreen, - ); - } + // The panel's own glyph already carries the tick, and its fill already + // says finished — a second green mark on the same line is the third time + // one fact is stated. + if (done) return const SizedBox.shrink(); + // ── Outstanding is amber, because that is what amber is for ── + // + // This wore `_legAccent`, so on a pickup it was brand red — the same ink + // as the header, the leg dot and the commit button. Red therefore said + // "this is Miler", "this is a pickup" and "you still owe me this" at once, + // and a word repeated three ways stops being read. + // + // Amber is the app's declared ink for something genuinely owed (see + // DESIGN_SYSTEM §4) and it is spent nowhere else on this screen, so the + // markers now separate from the page instead of blending into it. return Container( - padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h), + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), decoration: BoxDecoration( - color: ColorConstants.primary.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + color: ColorConstants.warning.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Text( 'Required', style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w800, - color: ColorConstants.primary, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), ), @@ -1913,6 +2086,15 @@ class _StopVerificationPageState extends State { child: AnimatedContainer( duration: const Duration(milliseconds: 160), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), + // ── One height, whatever the label does ── + // + // These sit in a two-up grid, and "Sealed & intact" wraps to two + // lines where "Not packed" does not — so one row of the grid stood a + // line taller than the other and the set stopped reading as a set. + // A floor, not a fixed height: at large system text a label that + // genuinely needs three lines still gets them. + constraints: BoxConstraints(minHeight: 52.h), + alignment: Alignment.centerLeft, decoration: BoxDecoration( color: selected ? accent : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), @@ -1926,7 +2108,14 @@ class _StopVerificationPageState extends State { ), ), child: Row( - mainAxisSize: MainAxisSize.min, + // ── Tight when the width is given, loose when it is not ── + // + // A chip in the grid is handed an exact width, so its label must + // take *exactly* what is left after the glyph — `Flexible` only + // caps a child at the space available and lets it keep its + // minimum intrinsic width, which at 2x text is wider than the + // chip and struck the page with an overflow band. + mainAxisSize: width == null ? MainAxisSize.min : MainAxisSize.max, children: [ Icon( icon, @@ -1940,18 +2129,22 @@ class _StopVerificationPageState extends State { // than the page, which overflowed instead of wrapping. Invisible at // normal text size; the only thing standing between a rider with big // type and a black-and-yellow band across his condition picker. - Flexible( - child: Text( + () { + final text = Text( label, maxLines: 2, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: selected ? Colors.white : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), - ), - ), + ); + return width == null + ? Flexible(child: text) + : Expanded(child: text); + }(), ], ), ), @@ -1977,7 +2170,7 @@ class _StopVerificationPageState extends State { onChanged: (_) => setState(() {}), style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -2003,11 +2196,17 @@ class _StopVerificationPageState extends State { // white borderless field is not a field. border: OutlineInputBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - borderSide: BorderSide(color: ColorConstants.borderStrong, width: 1.4), + borderSide: BorderSide( + color: ColorConstants.borderStrong, + width: 1.4, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - borderSide: BorderSide(color: ColorConstants.borderStrong, width: 1.4), + borderSide: BorderSide( + color: ColorConstants.borderStrong, + width: 1.4, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), @@ -2041,7 +2240,7 @@ class _StopVerificationPageState extends State { Expanded( child: MilerButton( label: 'Retake', - icon: Icons.refresh_rounded, + icon: LucideIcons.rotateCw, variant: MilerButtonVariant.outlined, color: ColorConstants.slateText, height: ButtonSizes.compact, @@ -2052,7 +2251,7 @@ class _StopVerificationPageState extends State { Expanded( child: MilerButton( label: 'Remove', - icon: Icons.delete_outline_rounded, + icon: LucideIcons.trash2, variant: MilerButtonVariant.outlined, color: ColorConstants.errorRed, height: ButtonSizes.compact, @@ -2077,22 +2276,19 @@ class _StopVerificationPageState extends State { decoration: BoxDecoration( color: accent.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: accent.withValues(alpha: 0.30), - width: 1.4, - ), + border: Border.all(color: accent.withValues(alpha: 0.30), width: 1.4), ), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.photo_camera_rounded, size: 27.sp, color: accent), + Icon(LucideIcons.camera, size: 27.sp, color: accent), SizedBox(height: 7.h), Text( 'Take photo', style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: accent, fontFamily: FontConstants.fontFamily, ), @@ -2113,7 +2309,7 @@ class _StopVerificationPageState extends State { ), child: Row( children: [ - Icon(Icons.info_rounded, size: 16.sp, color: accent), + Icon(LucideIcons.info, size: 16.sp, color: accent), SizedBox(width: 8.w), Expanded( child: Text( @@ -2121,7 +2317,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.35, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: accent, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/Profilepage.dart b/lib/views/Dashboard/profile/Profilepage.dart index f9eb5d6..fdece6b 100644 --- a/lib/views/Dashboard/profile/Profilepage.dart +++ b/lib/views/Dashboard/profile/Profilepage.dart @@ -1,8 +1,18 @@ +// ── One icon family ── +// +// Account was the last screen still drawing Material glyphs while Home, +// Deliveries and Activity had moved to Lucide. Two icon languages on one +// bottom-nav is visible the moment a rider switches tabs: Material's filled, +// optically heavier shapes beside Lucide's even 2px stroke read as two apps. +// +// Straight swaps — same meanings, same sizes, same tap targets. +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/profile/informations/profile.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/Dashboard/profile/informations/notifications_page.dart'; import 'package:miler/views/Dashboard/profile/informations/help_center.dart'; import 'package:miler/views/Dashboard/profile/informations/faq.dart'; @@ -11,7 +21,7 @@ import 'package:miler/views/Dashboard/profile/informations/terms_condition.dart' import 'package:miler/views/Dashboard/profile/informations/saved_address.dart'; import 'package:miler/views/Dashboard/profile/informations/order_alert_sound.dart'; import 'package:miler/views/Dashboard/summary/summary.dart'; -import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; @@ -21,7 +31,10 @@ import 'package:miler/views/helpers/widgets/settings_ui.dart'; import 'package:miler/widget/Bottom_page.dart'; import 'package:miler/views/onboardscreens/Sign_in.dart'; import 'package:miler/controllers/profile_controller.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/proof_store.dart'; import 'package:miler/controllers/rewards_controller.dart'; import 'package:miler/controllers/summary_controller.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -86,8 +99,14 @@ class _ProfilePageState extends State { if (!mounted) return; setState(() { _name = prefs.getString('user_name') ?? 'Rider Name'; - _email = prefs.getString('user_email') ?? 'rider@miler.com'; - _contact = prefs.getString('contactno') ?? 'Not added'; + // ── Empty, not invented ── + // + // These defaulted to `rider@miler.com` and `Not added`, and both were + // rendered as facts: a rider who had never entered an email read his own + // account page saying his address was `rider@miler.com`. A field with + // nothing behind it is a field the page does not draw. + _email = prefs.getString('user_email') ?? ''; + _contact = prefs.getString('contactno') ?? ''; _prefsLoaded = true; }); final userId = prefs.getInt('userid') ?? 0; @@ -111,6 +130,28 @@ class _ProfilePageState extends State { }); } + // ── The rider's control centre ──────────────────────────────────────────── + // + // Read top to bottom the page is: who you are → what you have earned → what + // you can change → who you can ask → the way out. Identity leads because the + // header is also the doorway to the one thing on this page a rider is + // likeliest to have got wrong about himself. + // + // ── Why it stopped being cards ── + // + // Everything above the section labels used to be a `SettingsCard`: a white, + // outlined, shadowed panel for the profile, and three more side by side for + // the figures. On a white page that is four boxes drawn round the first + // screenful, each with a border that has to be told apart from the paper + // behind it — and the three figure cards carried a saturated brand glyph + // apiece, so the loudest things on the rider's own account page were three + // decorative icons. + // + // The rows below them had already lost their boxes (see [SettingsGroup]). + // This is the top of the page catching up: the identity block sits on the + // paper, the figures share one quiet neutral surface, and the only fill left + // above the fold is the one the eye is meant to find. + @override Widget build(BuildContext context) { return Scaffold( @@ -122,19 +163,13 @@ class _ProfilePageState extends State { // rows on the bare background, so the whole tab read a shade dirtier than // the three either side of it. Four tabs of one app should not disagree // about what colour the paper is. - backgroundColor: ColorConstants.pureSurface, + // The brand shows through the sheet's two top corners — see [MilerSheet]. + backgroundColor: ColorConstants.primary, // Account was the odd tab out: no top bar at all, just a 28sp inline // heading scrolling away with the content, while the other three sat // under a fixed branded bar. Four tabs of one app should not disagree // about where the page title lives. - appBar: MilerAppBar( - title: 'Account', - trailing: _circleIconButton( - Icons.notifications_none_rounded, - () => openSheet(context, const NotificationsPage()), - semanticLabel: 'Notifications', - ), - ), + appBar: MilerAppBar(title: 'Account', trailing: _bell()), // ── Pull to refresh ── // // A pull here used to drag the page off the bottom of the screen and @@ -144,128 +179,208 @@ class _ProfilePageState extends State { // so it read as a refresh that had failed. Home was fixed this way; this // is the same fix, and now the pull actually re-reads the profile, the // points balance and the pickup count. - body: SettingsBody( - onRefresh: _refresh, - children: [ - // ── Who you are ── - _profileCard(), - SizedBox(height: 16.h), - - // ── The three numbers worth a tap ── - _quickActions(), - SizedBox(height: 22.h), - - // ── Earnings ── + // ── Account joins the ladder ── + // + // The tab was borderless rows on bare white — Uber's account idiom on a + // page that had no layers. The rest of the app now stands white panels + // on the canvas, and a rider swiping across the four tabs watched three + // layered pages and one flat one. Same recipe here: canvas ground, one + // panel per section, the labels on the canvas between them. + body: MilerSheet( + color: MilerSurface.canvas, + child: SettingsBody( + onRefresh: _refresh, + // The page's single left edge — the same gutter Activity uses, so a + // rider moving between tabs does not watch the content step inwards. + // ── The floating nav bar is 90-odd points tall ── // - // It used to be a tab of its own. A pay figure is a once-or-twice-a- - // day read, and a permanent quarter of the nav bar is the most - // expensive place in the app to keep one — so it lives here, at the - // top of Account, where the rest of "things about me rather than - // about this shift" already lives. The tab it vacated now shows what - // he has finished today, which is the glance he actually makes - // between stops. - const SettingsLabel('Money'), - SettingsGroup( - rows: [ - SettingsRow( - icon: Icons.account_balance_wallet_outlined, - title: 'Earnings', - subtitle: 'Today, this week and this month', - onTap: () => openSheet(context, const Summary()), - ), - ], + // This reserved 28, so the foot of the list could never be scrolled + // clear of it: Help & Support's second line and the rows under it sat + // behind the bar permanently, which reads as text truncated + // mid-sentence rather than as content hidden. [BottomPage.bottomInset] + // is the one place that height is known — the same call Activity and + // Deliveries make. + // Horizontal 0: each panel carries `MilerSurface.panelGutter` + // itself, exactly as Home's sections do. + padding: EdgeInsets.fromLTRB( + 0, + 10.h, + 0, + BottomPage.bottomInset(context) + 20.h, ), - SizedBox(height: 18.h), - - const SettingsLabel('Account'), - SettingsGroup( - rows: [ - SettingsRow( - icon: Icons.person_outline_rounded, - title: 'Edit Profile', - subtitle: _email.isNotEmpty ? _email : null, - onTap: () => openSheet(context, const Profile()), - ), - SettingsRow( - icon: Icons.location_on_outlined, - title: 'Saved Address', - subtitle: 'Where your shift starts and ends', - onTap: () => openSheet(context, const SavedAddressPage()), - ), - SettingsRow( - icon: Icons.notifications_active_outlined, - title: 'Alert Sound', - subtitle: 'What a new booking sounds like', - onTap: () => openSheet(context, const OrderAlertSoundPage()), - ), - SettingsRow( - icon: Icons.notifications_none_rounded, - title: 'Notifications', - onTap: () => openSheet(context, const NotificationsPage()), - ), - ], - ), - SizedBox(height: 22.h), - - const SettingsLabel('Support'), - SettingsGroup( - rows: [ - SettingsRow( - icon: Icons.headset_mic_outlined, - title: 'Help & Support', - subtitle: 'Message the team — we reply within a day', - onTap: () => openSheet(context, const HelpCenter()), - ), - SettingsRow( - icon: Icons.quiz_outlined, - title: 'FAQ', - subtitle: 'Answers to the usual questions', - onTap: () => openSheet(context, const FaqPage()), - ), - SettingsRow( - icon: Icons.description_outlined, - title: 'Terms & Conditions', - onTap: () => openSheet(context, const TermsCondition()), - ), - ], - ), - SizedBox(height: 16.h), - - // ── SOS ── - _buildSOSBanner(), - SizedBox(height: 18.h), - - SettingsGroup( - rows: [ - SettingsRow( - icon: Icons.logout_rounded, - title: 'Logout', - danger: true, - onTap: () => _showLogoutDialog(context), - ), - ], - ), - - SizedBox(height: 22.h), - Center( - child: Text( - "Miler ${_appVersion.isNotEmpty ? _appVersion : ''}\n© 2026 Doormile Logistics" - .trim(), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11.sp, - color: ColorConstants.onSurfaceVariant, - fontFamily: FontConstants.fontFamily, - height: 1.6, + children: [ + // ── Who you are, and the day's three figures: one card ── + MilerPanel( + gap: 24, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _identity(), + SizedBox(height: 12.h), + _quickStrip(), + _figuresNotice(), + ], ), ), - ), - SizedBox(height: 20.h), - ], + + // ── Earnings ── + // + // It used to be a tab of its own. A pay figure is a once-or-twice-a- + // day read, and a permanent quarter of the nav bar is the most + // expensive place in the app to keep one — so it lives here, at the + // top of Account, where the rest of "things about me rather than + // about this shift" already lives. The tab it vacated now shows what + // he has finished today, which is the glance he actually makes + // between stops. + Padding( + padding: EdgeInsetsDirectional.only( + start: MilerSurface.panelGutter + MilerSurface.panelPad, + ), + child: const SettingsLabel('Money'), + ), + MilerPanel( + padded: false, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + child: SettingsGroup( + rows: [ + SettingsRow( + icon: LucideIcons.wallet, + title: 'Earnings', + subtitle: 'Today, this week and this month', + onTap: () => openSheet(context, const Summary()), + ), + ], + ), + ), + ), + SizedBox(height: 20.h), + + Padding( + padding: EdgeInsetsDirectional.only( + start: MilerSurface.panelGutter + MilerSurface.panelPad, + ), + child: const SettingsLabel('Account'), + ), + MilerPanel( + padded: false, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + child: SettingsGroup( + rows: [ + SettingsRow( + icon: LucideIcons.user, + title: 'Edit Profile', + subtitle: _email.isNotEmpty ? _email : null, + onTap: () => openSheet(context, const Profile()), + ), + SettingsRow( + icon: LucideIcons.mapPin, + title: 'Saved Address', + subtitle: 'Where your shift starts and ends', + onTap: () => openSheet(context, const SavedAddressPage()), + ), + SettingsRow( + icon: LucideIcons.bellRing, + title: 'Alert Sound', + // The line's own noun, never a literal. It reads "booking" on + // a parcel route and "delivery" on a milk run, which is the + // same word the nav bar, the accepted pill and the empty + // states are already using — see [ServiceProfile.jobNoun]. + subtitle: + 'What a new ${ServiceProfile.active.jobNoun} sounds like', + onTap: () => + openSheet(context, const OrderAlertSoundPage()), + ), + SettingsRow( + icon: LucideIcons.bell, + title: 'Notifications', + onTap: () => + openSheet(context, const NotificationsPage()), + ), + ], + ), + ), + ), + SizedBox(height: 20.h), + + Padding( + padding: EdgeInsetsDirectional.only( + start: MilerSurface.panelGutter + MilerSurface.panelPad, + ), + child: const SettingsLabel('Support'), + ), + MilerPanel( + padded: false, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + child: SettingsGroup( + rows: [ + SettingsRow( + icon: LucideIcons.headphones, + title: 'Help & Support', + subtitle: 'Message the team — we reply within a day', + onTap: () => openSheet(context, const HelpCenter()), + ), + SettingsRow( + icon: LucideIcons.circleHelp, + title: 'FAQ', + subtitle: 'Answers to the usual questions', + onTap: () => openSheet(context, const FaqPage()), + ), + SettingsRow( + icon: LucideIcons.fileText, + title: 'Terms & Conditions', + onTap: () => openSheet(context, const TermsCondition()), + ), + ], + ), + ), + ), + SizedBox(height: 26.h), + + // ── SOS ── + _emergency(), + + // ── Ending the session is not a preference ── + // + // Logout sat 18pt under the emergency banner in the same group shape + // as Alert Sound, which put "sign me out" and "which noise a booking + // makes" on one visual footing. It is below a rule now, with a + // screenful of air above it, in the destructive red rather than the + // brand's. + SizedBox(height: 30.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 4.h), + MilerPanel( + padded: false, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + child: SettingsGroup( + rows: [ + SettingsRow( + icon: LucideIcons.logOut, + title: 'Logout', + danger: true, + onTap: () => _showLogoutDialog(context), + ), + ], + ), + ), + ), + + SizedBox(height: 26.h), + _footer(), + ], + ), ), ); } + /// The page's single left edge. Matches Activity's, so the four tabs share + /// one margin. + static double get _gutter => 20.w; + /// Re-reads everything the page shows. Deliberately awaits, so the spinner /// stays down until the numbers are actually new. Future _refresh() async { @@ -274,141 +389,90 @@ class _ProfilePageState extends State { await _loadAppVersion(); } - // ─── Section label ─────────────────────────────────────────────────────────── + // ─── The bell ──────────────────────────────────────────────────────────── - // ─── Header icon button ────────────────────────────────────────────────────── - Widget _circleIconButton( - IconData icon, - VoidCallback onTap, { - required String semanticLabel, - }) { + /// Unfilled, unoutlined, unshadowed. + /// + /// It was a 42pt white disc with a hairline border and a drop shadow, sitting + /// on the app bar's frosted wash — a container, drawn round a glyph, on a + /// surface that is already a surface. What a rider needs from it is a target + /// he can hit without looking, which is the [PressScale] and the 44pt box, + /// not the ring. + Widget _bell() { return PressScale( - onTap: onTap, - semanticLabel: semanticLabel, + onTap: () => openSheet(context, const NotificationsPage()), + semanticLabel: 'Notifications', minTarget: true, - child: Container( - width: 42.w, - height: 42.w, - decoration: BoxDecoration( - color: ColorConstants.surfaceContainerLowest, - shape: BoxShape.circle, - border: Border.all(color: ColorConstants.dividerColor), - boxShadow: [ - BoxShadow( - color: ColorConstants.onSurface.withValues(alpha: 0.05), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], + child: SizedBox( + width: ButtonSizes.minTapTarget, + height: ButtonSizes.minTapTarget, + child: Icon( + LucideIcons.bell, + // On the brand ground — see [MilerAppBar]. + color: Colors.white, + size: 23.sp, ), - child: Icon(icon, color: ColorConstants.onSurface, size: 22.sp), ), ); } - // ─── Profile card ──────────────────────────────────────────────────────────── - Widget _profileCard() { + // ─── Identity ──────────────────────────────────────────────────────────── + + /// Avatar, name, number, and the line of work he is on — on the page, not in + /// a box. + /// + /// The whole block opens Edit Profile, which is why the "View profile" pill + /// that used to sit under the number is gone: a tappable row with a chevron + /// on the end already says where it goes, and the pill was a second control + /// for the same destination taking a third line to say so. + Widget _identity() { return PressScale( onTap: () => openSheet(context, const Profile()), - child: Container( - padding: EdgeInsets.all(16.w), - decoration: SettingsCard.decoration(), + semanticLabel: 'Your profile', + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8.h), child: Row( children: [ - Obx(() { - final path = _profileController.imagePath.value; - bool hasImage = false; - try { - hasImage = path.isNotEmpty && File(path).existsSync(); - } catch (_) {} - return Container( - width: 64.w, - height: 64.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: ColorConstants.neutralLight, - image: hasImage - ? DecorationImage( - image: FileImage(File(path)), - fit: BoxFit.cover, - ) - : null, - ), - child: !hasImage - ? Icon( - Icons.person_rounded, - color: ColorConstants.onSurfaceVariant, - size: 34.sp, - ) - : null, - ); - }), - SizedBox(width: 14.w), + _avatar(), + SizedBox(width: 15.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!_prefsLoaded) ...[ + // ── The card waits before it speaks ── + // + // Name and number come off disk. Until they land this said + // "Rider Name / Not added" as if that were the answer, so the + // rider's first glimpse of his own account told him his + // number was missing. + if (!_prefsLoaded) MilerShimmer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SkeletonBone(width: 150.w, height: 18.h), - SizedBox(height: 7.h), - SkeletonBone(width: 110.w, height: 13.h), + SkeletonBone(width: 156.w, height: 19.h, radius: 6), + SizedBox(height: 9.h), + SkeletonBone(width: 208.w, height: 13.h, radius: 6), ], ), - ), - ] else ...[ + ) + else ...[ Text( - _name.isNotEmpty ? _name : "Rider Name", + _name.isNotEmpty ? _name : 'Rider Name', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.onSurface, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 3.h), - Text( - _contact.isNotEmpty ? _contact : "Not added", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - color: ColorConstants.onSurfaceVariant, - fontFamily: FontConstants.fontFamily, - ), + style: MilerType.cardTitle.copyWith(fontSize: 20.sp), ), + SizedBox(height: 4.h), + _identityLine(), ], - SizedBox(height: 8.h), - Container( - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 4.h, - ), - decoration: BoxDecoration( - color: ColorConstants.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), - child: Text( - "View profile", - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.primary, - fontFamily: FontConstants.fontFamily, - ), - ), - ), ], ), ), + SizedBox(width: 8.w), Icon( - Icons.chevron_right_rounded, - color: ColorConstants.onSurfaceVariant, + LucideIcons.chevronRight, + color: ColorConstants.borderStrong, size: 22.sp, ), ], @@ -417,92 +481,149 @@ class _ProfilePageState extends State { ); } - // ─── Quick actions ─────────────────────────────────────────────────────────── - Widget _quickActions() { - return Obx( - () => Row( - children: [ - _quickCard( - Icons.card_giftcard_rounded, - "Rewards", - value: _formatCount(_rewardsController.totalPoints.value), - loading: _rewardsController.isLoading.value, - onTap: () => openSheet(context, const RiderRewardsPage()), - ), - SizedBox(width: 12.w), - _quickCard( - Icons.local_shipping_rounded, - "Pickups", - value: _formatCount(_summaryController.total.value), - loading: _summaryController.isLoading.value, - // Was `onTap: () {}` — a card that looks tappable, sits next to - // two that are, and does nothing. It shows the rider's lifetime - // pickup count, so it goes where that count comes from. - onTap: () => BottomPage.goToTab(1), - ), - SizedBox(width: 12.w), - _quickCard( - Icons.headset_mic_rounded, - "Help", - value: "24/7", - onTap: () => openSheet(context, const HelpCenter()), - ), + /// `+91 98765 43210 · Doormile Milk Man` + /// + /// ── Why the line of work is here ── + /// + /// It is the second thing about a rider that is true all day and the first + /// one he cannot check anywhere else in the app: every screen adapts to it — + /// the tab is called Deliveries or Bookings, money is collected or it is not + /// — and nothing until now actually *named* it. It reads + /// [ServiceProfile.active], never a tenant id or a line name, so a third kind + /// of client is a new profile rather than a new branch here. + /// + /// A [Wrap], because at 2× text scale a number and a service name are longer + /// than the phone and the line has to fold rather than clip. + Widget _identityLine() { + final String contact = _contact.trim(); + final String line = ServiceProfile.active.label.trim(); + + final parts = [ + if (contact.isNotEmpty) + Text(contact, maxLines: 1, style: MilerType.label), + if (line.isNotEmpty) + Text(line, maxLines: 1, style: MilerType.label.semibold), + ]; + + if (parts.isEmpty) { + return Text('Add your number in Edit Profile', style: MilerType.label); + } + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 7.w, + runSpacing: 2.h, + children: [ + for (final (i, p) in parts.indexed) ...[ + if (i > 0) + Text( + '·', + style: MilerType.label.copyWith( + fontWeight: FontWeight.w700, + color: ColorConstants.borderStrong, + ), + ), + p, ], - ), + ], ); } - Widget _quickCard( - IconData icon, - String label, { - String? value, - VoidCallback? onTap, - bool loading = false, - }) { - return Expanded( - child: PressScale( - onTap: onTap, - child: Container( - padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 6.w), - decoration: SettingsCard.decoration(), - child: Column( + /// The rider's photo, or the glyph that stands in for one. + /// + /// Guarded by an `existsSync`: an image-picker path points into a cache the + /// OS is free to clear, so a photo that is no longer on disk falls back to + /// the glyph rather than throwing behind a scroll view. + Widget _avatar() { + return Obx(() { + final path = _profileController.imagePath.value; + bool hasImage = false; + try { + hasImage = path.isNotEmpty && File(path).existsSync(); + } catch (_) {} + return Container( + width: 60.w, + height: 60.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + // A tint of the brand rather than a neutral disc — this is identity, + // which is the one thing on this screen the accent is for. + color: hasImage + ? ColorConstants.neutralLight + : ColorConstants.primary.withValues(alpha: 0.10), + image: hasImage + ? DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover) + : null, + ), + child: !hasImage + ? Icon( + LucideIcons.user, + color: ColorConstants.borderStrong, + size: 32.sp, + ) + : null, + ); + }); + } + + // ─── The three figures ─────────────────────────────────────────────────── + + /// One surface, three cells, two hairlines. + /// + /// These were three separate outlined cards with a saturated brand glyph on + /// each. Three boxes to hold three numbers is three times the container for + /// the same content — and the glyphs were decoration: nothing about a gift + /// icon tells a rider what a points balance is that the word "Rewards" + /// underneath it did not already say. + /// + /// One quiet neutral fill now, divided rather than repeated, with the figures + /// leading and the labels under them. It is the calmest shape that still + /// reads as three things you can press. + Widget _quickStrip() { + return Obx( + () => Container( + decoration: BoxDecoration( + // The canvas well, not `neutralLight`: inside the account card the + // old fill measured 1.02:1 against the white and the strip's three + // figures floated on nothing. + color: MilerSurface.canvas, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon(icon, color: ColorConstants.primary, size: 24.sp), - SizedBox(height: 8.h), - if (value != null) ...[ - // A stale zero reads as a real number. While the count is in - // flight, show that it is in flight. - if (loading) - Padding( - padding: EdgeInsets.symmetric(vertical: 3.h), - child: MilerShimmer( - child: SkeletonBone(width: 34.w, height: 15.h), - ), - ) - else - Text( - value, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w800, - color: ColorConstants.onSurface, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 1.h), - ], - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.sp, - fontWeight: value != null ? FontWeight.w500 : FontWeight.w600, - color: value != null - ? ColorConstants.onSurfaceVariant - : ColorConstants.onSurface, - fontFamily: FontConstants.fontFamily, - ), + _figureCell( + label: 'Rewards', + // A points balance the server could not be asked for is a gap, + // not a zero. A stale zero on a screen about somebody's pay is + // the one mistake worth writing extra code to avoid — see the + // note on [RewardsController]. + value: _rewardsController.error.value.isNotEmpty + ? null + : _formatCount(_rewardsController.totalPoints.value), + loading: _rewardsController.isLoading.value, + onTap: () => openSheet(context, const RiderRewardsPage()), + ), + _cellRule(), + _figureCell( + // Named for the line, and for where the card goes. "Pickups" + // was a literal, and on a milk run — where every stop at a + // customer's door is a drop — it labelled the rider's whole + // year of work with the one thing he does at a kitchen. + label: ServiceProfile.active.workTabLabel, + value: _formatCount(_summaryController.total.value), + loading: _summaryController.isLoading.value, + // Was `onTap: () {}` — a card that looks tappable, sits next to + // two that are, and does nothing. It shows the rider's lifetime + // pickup count, so it goes where that count comes from. + onTap: () => BottomPage.goToTab(1), + ), + _cellRule(), + _figureCell( + label: 'Help', + value: '24/7', + onTap: () => openSheet(context, const HelpCenter()), ), ], ), @@ -511,6 +632,115 @@ class _ProfilePageState extends State { ); } + Widget _cellRule() => Padding( + padding: EdgeInsets.symmetric(vertical: 14.h), + child: Container(width: 1, color: ColorConstants.borderSubtle), + ); + + Widget _figureCell({ + required String label, + required String? value, + required VoidCallback onTap, + bool loading = false, + }) { + return Expanded( + child: PressScale( + onTap: onTap, + semanticLabel: '$label, ${value ?? 'not available'}', + child: Padding( + padding: EdgeInsets.symmetric(vertical: 15.h, horizontal: 6.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // A stale zero reads as a real number. While the count is in + // flight, show that it is in flight. + if (loading) + Padding( + padding: EdgeInsets.symmetric(vertical: 3.h), + child: MilerShimmer( + child: SkeletonBone(width: 38.w, height: 16.h, radius: 6), + ), + ) + else + // Scaled down rather than truncated. At 2× text scale a + // four-digit balance ellipsised to "1,2…", which is a number + // nobody can read — and a figure that cannot be read is worse + // than a smaller one. + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value ?? '—', + maxLines: 1, + style: MilerType.figure( + 19, + color: value == null + ? ColorConstants.secondaryText + : ColorConstants.slateText, + ), + ), + ), + SizedBox(height: 4.h), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.micro, + ), + ], + ), + ), + ), + ); + } + + /// One line, only when the figures above it could not be fetched. + /// + /// Not a card and not a full-screen error: the rest of this page is read off + /// the phone and is perfectly good without a network, so a failed figure is + /// worth a sentence and a retry — not a state the whole tab has to sit in. + Widget _figuresNotice() { + return Obx(() { + if (_rewardsController.error.value.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: EdgeInsets.only(top: 10.h), + child: Row( + children: [ + Icon( + LucideIcons.cloudOff, + size: 14.sp, + color: ColorConstants.warning, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + 'Could not refresh these figures', + maxLines: 2, + style: MilerType.micro.on(ColorConstants.warning), + ), + ), + SizedBox(width: 8.w), + PressScale( + onTap: _refresh, + semanticLabel: 'Retry', + minTarget: true, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 4.h), + child: Text( + 'Retry', + style: MilerType.micro + .on(ColorConstants.primary) + .copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ); + }); + } + /// Formats an integer with thousands separators (e.g. 12450 -> "12,450"). String _formatCount(int value) { final s = value.toString(); @@ -522,6 +752,8 @@ class _ProfilePageState extends State { return buf.toString(); } + // ─── SOS ───────────────────────────────────────────────────────────────── + /// India's emergency number. This was **911**, which does nothing on an /// Indian network — the one control in the app that only matters when a rider /// has been hit or threatened, wired to a number that cannot answer. @@ -548,21 +780,26 @@ class _ProfilePageState extends State { } } - // ─── SOS banner ───────────────────────────────────────────────────────────── + /// See [EmergencyRow] — one widget, shared with Help & Support, so the + /// sentence promising the rider what a press will do cannot exist in two + /// versions. + Widget _emergency() => + EmergencyRow(number: _emergencyNumber, onTap: _callEmergency); - Widget _buildSOSBanner() { - return GestureDetector( - onTap: _callEmergency, - child: InfoBanner( - text: - "Emergency Response — Tap to call SOS if you are in immediate danger.", - icon: Icons.warning_amber_rounded, - color: ColorConstants.primary, + // ─── Footer ────────────────────────────────────────────────────────────── + + Widget _footer() { + return Center( + child: Text( + 'Miler ${_appVersion.isNotEmpty ? _appVersion : ''}\n' + '© 2026 Doormile Logistics' + .trim(), + textAlign: TextAlign.center, + style: MilerType.micro.copyWith(height: 1.7), ), ); } } - // ─── Logout dialog ──────────────────────────────────────────────────────────── void _showLogoutDialog(BuildContext context) { @@ -574,22 +811,11 @@ void _showLogoutDialog(BuildContext context) { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), - title: Text( - "Logout", - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w700, - fontFamily: FontConstants.fontFamily, - color: ColorConstants.onSurface, - ), - ), + title: Text('Log out?', style: MilerType.cardTitle), content: Text( - "Are you sure you want to logout?", - style: TextStyle( - fontSize: 14.sp, - color: ColorConstants.onSurfaceVariant, - fontFamily: FontConstants.fontFamily, - ), + 'Your work for today stays on this phone. You will sign back in with ' + 'your PIN.', + style: MilerType.caption, ), actionsPadding: EdgeInsets.fromLTRB(20.w, 4.h, 20.w, 18.h), actions: [ @@ -607,10 +833,27 @@ void _showLogoutDialog(BuildContext context) { SizedBox(width: 12.w), Expanded( child: MilerButton( - label: "Logout", + label: 'Log out', + // Destructive, so it is drawn destructive. The brand red on + // this button made the action that ends the session look + // like every other primary action in the app. + color: ColorConstants.errorRed, onPressed: () async { + // ── The next rider on this handset starts empty ── + // + // Finished work, skipped stops, carried bags and bag + // labels are local, and they used to sit under global + // keys — so signing out and back in as somebody else + // showed them the previous rider's history. They are + // scoped per rider/tenant/line now ([WorkScope]), and + // this drops *this* scope's drawer on the way out so + // nothing survives the session that created it. + await clearScopedStores(); + // Doorstep photos are exactly the kind of record that must + // not outlive the session that took them. + await ProofStore.clearScope(); final prefs = await SharedPreferences.getInstance(); - prefs.setBool('logged_out', true); + await prefs.setBool('logged_out', true); Get.offAll(() => const SignIn()); }, height: ButtonSizes.secondary, diff --git a/lib/views/Dashboard/profile/informations/faq.dart b/lib/views/Dashboard/profile/informations/faq.dart index 7128cfd..857e2de 100644 --- a/lib/views/Dashboard/profile/informations/faq.dart +++ b/lib/views/Dashboard/profile/informations/faq.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -89,13 +90,13 @@ class _FaqPageState extends State { ), _Faq( 'I did not hear a new booking arrive.', - 'Check Account → Alert Sound, play the sound to be sure you can hear it ' + 'Check Account > Alert Sound, play the sound to be sure you can hear it ' 'over traffic, and make sure the phone is not on silent. Miler also ' 'needs battery optimisation turned off to alert you reliably.', ), _Faq( 'How do I change my name, photo or address?', - 'Account → Edit Profile for your name, photo and email; Account → Saved ' + 'Account > Edit Profile for your name, photo and email; Account > Saved ' 'Address for your address. Your mobile number is fixed to the one ' 'your hub registered — contact support to change it.', ), @@ -173,7 +174,7 @@ class _FaqPageState extends State { onChanged: (v) => setState(() => _query = v), style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -186,7 +187,7 @@ class _FaqPageState extends State { fontFamily: FontConstants.fontFamily, ), prefixIcon: Icon( - Icons.search_rounded, + LucideIcons.search, size: 20.sp, color: ColorConstants.onSurfaceVariant, ), @@ -195,7 +196,7 @@ class _FaqPageState extends State { : IconButton( tooltip: 'Close', icon: Icon( - Icons.close_rounded, + LucideIcons.x, size: 18.sp, color: ColorConstants.onSurfaceVariant, ), @@ -222,7 +223,7 @@ class _FaqPageState extends State { child: Column( children: [ Icon( - Icons.search_off_rounded, + LucideIcons.searchX, size: 34.sp, color: ColorConstants.onSurfaceVariant, ), @@ -232,7 +233,7 @@ class _FaqPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -265,7 +266,7 @@ class _FaqPageState extends State { 'Still stuck?', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -283,7 +284,7 @@ class _FaqPageState extends State { SizedBox(height: 14.h), MilerButton( label: 'Contact support', - icon: Icons.mail_outline_rounded, + icon: LucideIcons.mail, variant: MilerButtonVariant.tonal, height: ButtonSizes.secondary, onPressed: () => openSheet(context, const HelpCenterMessage()), @@ -329,7 +330,7 @@ class _FaqTileState extends State<_FaqTile> { widget.faq.question, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, height: 1.35, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, @@ -342,7 +343,7 @@ class _FaqTileState extends State<_FaqTile> { duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, child: Icon( - Icons.expand_more_rounded, + LucideIcons.chevronDown, size: 22.sp, color: _open ? ColorConstants.primary diff --git a/lib/views/Dashboard/profile/informations/help_center.dart b/lib/views/Dashboard/profile/informations/help_center.dart index e6aa1f5..3898a9b 100644 --- a/lib/views/Dashboard/profile/informations/help_center.dart +++ b/lib/views/Dashboard/profile/informations/help_center.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -30,6 +31,9 @@ class HelpCenter extends StatelessWidget { /// The hub's support line. One constant, used by the call row. static const String _supportPhone = '+918925545000'; + /// India's emergency number — see the note on `ProfilePage`. + static const String _emergencyNumber = '112'; + @override Widget build(BuildContext context) { return Scaffold( @@ -43,13 +47,13 @@ class HelpCenter extends StatelessWidget { SettingsGroup( rows: [ SettingsRow( - icon: Icons.mail_outline_rounded, + icon: LucideIcons.mail, title: 'Send a message', subtitle: 'We usually reply within a day', onTap: () => openSheet(context, const HelpCenterMessage()), ), SettingsRow( - icon: Icons.call_outlined, + icon: LucideIcons.phone, title: 'Call the hub', subtitle: 'For anything that cannot wait', onTap: () => _dial(context, _supportPhone), @@ -61,7 +65,7 @@ class HelpCenter extends StatelessWidget { SettingsGroup( rows: [ SettingsRow( - icon: Icons.quiz_outlined, + icon: LucideIcons.circleHelp, title: 'Browse the FAQ', subtitle: 'Trips, skips, cash, alerts and payments', onTap: () => openSheet(context, const FaqPage()), @@ -86,7 +90,7 @@ class HelpCenter extends StatelessWidget { 'How can we help?', style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.4, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, @@ -108,19 +112,13 @@ class HelpCenter extends StatelessWidget { ); } - /// Distinct from support: this is the one for an accident or a threat, and it - /// is deliberately the only red-filled thing on the page. - Widget _emergency(BuildContext context) { - return GestureDetector( - onTap: () => _dial(context, '112'), - child: InfoBanner( - text: - 'Emergency — tap to call for help if you are in immediate danger.', - icon: Icons.warning_amber_rounded, - color: ColorConstants.primary, - ), - ); - } + /// Distinct from support: this is the one for an accident or a threat, not + /// for a customer who will not pay. Same [EmergencyRow] the Account page + /// draws — it used to be a second, differently-worded copy of it. + Widget _emergency(BuildContext context) => EmergencyRow( + number: _emergencyNumber, + onTap: () => _dial(context, _emergencyNumber), + ); /// Says so when the dialer will not open, rather than absorbing the tap. static Future _dial(BuildContext context, String number) async { @@ -222,7 +220,7 @@ class _HelpCenterMessageState extends State { 'What has happened?', style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -284,7 +282,7 @@ class _HelpCenterMessageState extends State { text, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -304,7 +302,7 @@ class _HelpCenterMessageState extends State { validator: validator, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/informations/notifications_page.dart b/lib/views/Dashboard/profile/informations/notifications_page.dart index f1e6a4b..5782fb6 100644 --- a/lib/views/Dashboard/profile/informations/notifications_page.dart +++ b/lib/views/Dashboard/profile/informations/notifications_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -11,7 +12,6 @@ import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; -import 'package:miler/views/helpers/widgets/settings_ui.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -118,7 +118,7 @@ class _NotificationsPageState extends State { 'Clear notifications?', style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -140,7 +140,7 @@ class _NotificationsPageState extends State { 'Cancel', style: TextStyle( color: ColorConstants.onSurfaceVariant, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontFamily: FontConstants.fontFamily, ), ), @@ -151,7 +151,7 @@ class _NotificationsPageState extends State { 'Clear', style: TextStyle( color: ColorConstants.primary, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -181,7 +181,7 @@ class _NotificationsPageState extends State { 'Clear', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -194,7 +194,7 @@ class _NotificationsPageState extends State { ? const SkeletonList(count: 5) : _items.isEmpty ? const MilerEmptyState( - icon: Icons.notifications_none_rounded, + icon: LucideIcons.bell, title: 'No notifications yet', message: 'Updates about your bookings, your shift and your ' @@ -204,17 +204,30 @@ class _NotificationsPageState extends State { onRefresh: _load, color: ColorConstants.primary, backgroundColor: ColorConstants.surfaceContainerLowest, + // ── A ruled list, not a stack of cards ── + // + // Eight shadowed white cards, each holding one sentence behind + // an identical icon disc, was the exact idiom Activity and + // Account were rebuilt to remove — notifications are the most + // list-shaped content in the app and were the last screen + // still wearing boxes. Hairlines separate; the tinted glyph + // anchors; nothing needs a container to be one item. child: ListView.separated( physics: const AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h), + padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 28.h), itemCount: _items.length, - separatorBuilder: (_, _) => SizedBox(height: 12.h), + separatorBuilder: (_, _) => Divider( + height: 1, + thickness: 1, + indent: 52.w, + color: ColorConstants.borderSubtle, + ), itemBuilder: (context, index) { final it = _items[index]; return Reveal( key: ValueKey('notif_${it['id'] ?? index}'), delay: staggerDelay(index), - child: _NotificationCard(item: it), + child: _NotificationRow(item: it), ); }, ), @@ -224,41 +237,57 @@ class _NotificationsPageState extends State { } } -/// One notification, in the section's card. +/// One notification, as one row of the ruled list. /// -/// The kind badge is the only colour on the card, and it comes from the token -/// set: brand red for work, the money green for earnings, the info blue for -/// everything else. It used to be `Colors.green.shade700` on `green.shade50`, -/// `Colors.blue.shade700` on `blue.shade50` and a raw `0xFFFEE2E2` — three -/// palettes that appear nowhere else in the app. -class _NotificationCard extends StatelessWidget { +/// The kind glyph is the only colour on the row, and it comes from the token +/// set: brand red for work arriving, the app's done-green for work finished, +/// the money green for earnings, the info blue for everything else. A +/// completed booking used to wear the same truck as a new assignment — one +/// glyph for opposite facts — which is what made eight rows read as one row +/// eight times. +class _NotificationRow extends StatelessWidget { final Map item; - const _NotificationCard({required this.item}); + const _NotificationRow({required this.item}); ({IconData icon, Color accent}) _kind() { final type = (item['type'] ?? '').toString().toLowerCase(); final title = (item['title'] ?? '').toString().toLowerCase(); + // Finished work first — its titles also contain 'booking', so the + // completed test must run before the assignment test or it never fires. + if (title.contains('completed') || + title.contains('delivered') || + type == 'completed') { + return ( + icon: LucideIcons.circleCheck, + accent: ColorConstants.acceptGreen, + ); + } if (type == 'earning' || title.contains('payment') || title.contains('payout') || title.contains('earning')) { - return ( - icon: Icons.account_balance_wallet_outlined, - accent: ColorConstants.moneyGreen, - ); + return (icon: LucideIcons.wallet, accent: ColorConstants.moneyGreen); } if (type == 'task' || title.contains('pickup') || title.contains('booking') || title.contains('trip')) { - return ( - icon: Icons.local_shipping_outlined, - accent: ColorConstants.primary, - ); + return (icon: LucideIcons.truck, accent: ColorConstants.primary); } - return (icon: Icons.info_outline_rounded, accent: ColorConstants.tertiary); + return (icon: LucideIcons.info, accent: ColorConstants.tertiary); + } + + /// The server's relative stamps arrive ungrammatical — `1 hrs ago`, + /// `1 mins ago` — and were printed verbatim. Singular units drop the s; + /// everything else passes through untouched. + static String tidyTime(String raw) { + final t = raw.trim(); + final m = RegExp(r'^1\s+(hrs|mins|secs|days)\s+ago$').firstMatch(t); + if (m == null) return t; + final unit = m.group(1)!; + return '1 ${unit.substring(0, unit.length - 1)} ago'; } @override @@ -266,12 +295,12 @@ class _NotificationCard extends StatelessWidget { final kind = _kind(); final title = (item['title'] ?? 'Miler').toString(); final body = (item['body'] ?? '').toString(); - final time = (item['time'] ?? 'Just now').toString(); + final time = tidyTime((item['time'] ?? 'Just now').toString()); final imageUrl = (item['imageUrl'] ?? '').toString(); final imagePath = (item['imagePath'] ?? '').toString(); - return SettingsCard( - padding: EdgeInsets.all(16.w), + return Padding( + padding: EdgeInsets.symmetric(vertical: 14.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -279,13 +308,13 @@ class _NotificationCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 42.w, - height: 42.w, + width: 38.w, + height: 38.w, decoration: BoxDecoration( color: kind.accent.withValues(alpha: 0.10), shape: BoxShape.circle, ), - child: Icon(kind.icon, color: kind.accent, size: 21.sp), + child: Icon(kind.icon, color: kind.accent, size: 19.sp), ), SizedBox(width: 14.w), Expanded( @@ -300,7 +329,7 @@ class _NotificationCard extends StatelessWidget { title, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, height: 1.3, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, @@ -312,7 +341,7 @@ class _NotificationCard extends StatelessWidget { time, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/informations/order_alert_sound.dart b/lib/views/Dashboard/profile/informations/order_alert_sound.dart index defa1eb..cf98c00 100644 --- a/lib/views/Dashboard/profile/informations/order_alert_sound.dart +++ b/lib/views/Dashboard/profile/informations/order_alert_sound.dart @@ -1,5 +1,6 @@ import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -152,7 +153,7 @@ class _OrderAlertSoundPageState extends State { shape: BoxShape.circle, ), child: Icon( - Icons.notifications_active_rounded, + LucideIcons.bellRing, color: ColorConstants.primary, size: 23.sp, ), @@ -166,7 +167,7 @@ class _OrderAlertSoundPageState extends State { _currentLabel, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -197,9 +198,7 @@ class _OrderAlertSoundPageState extends State { Widget _soundRow(_SoundOption opt) { final bool isSelected = _selected == opt.assetPath; return SettingsRow( - icon: isSelected - ? Icons.check_circle_rounded - : Icons.radio_button_unchecked_rounded, + icon: isSelected ? LucideIcons.circleCheck : LucideIcons.circle, iconColor: isSelected ? ColorConstants.primary : ColorConstants.onSurfaceVariant, @@ -224,7 +223,7 @@ class _OrderAlertSoundPageState extends State { shape: BoxShape.circle, ), child: Icon( - isPlaying ? Icons.graphic_eq_rounded : Icons.play_arrow_rounded, + isPlaying ? LucideIcons.audioLines : LucideIcons.play, size: 20.sp, color: isPlaying ? ColorConstants.onPrimary diff --git a/lib/views/Dashboard/profile/informations/profile.dart b/lib/views/Dashboard/profile/informations/profile.dart index 792f98a..ff469e3 100644 --- a/lib/views/Dashboard/profile/informations/profile.dart +++ b/lib/views/Dashboard/profile/informations/profile.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; @@ -146,7 +147,7 @@ class _ProfileState extends State { child: TextButton.icon( onPressed: _pickImage, icon: Icon( - Icons.photo_camera_outlined, + LucideIcons.camera, size: 16.sp, color: ColorConstants.primary, ), @@ -154,7 +155,7 @@ class _ProfileState extends State { 'Change Photo', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -165,14 +166,14 @@ class _ProfileState extends State { _field( label: 'Full name', controller: _nameCtrl, - icon: Icons.person_outline_rounded, + icon: LucideIcons.user, hint: 'Enter your name', ), SizedBox(height: 16.h), _field( label: 'Mobile number', controller: _contactCtrl, - icon: Icons.phone_outlined, + icon: LucideIcons.phone, hint: 'Mobile number', readOnly: true, prefix: '+91 ', @@ -181,7 +182,7 @@ class _ProfileState extends State { _field( label: 'Email address', controller: _emailCtrl, - icon: Icons.mail_outline_rounded, + icon: LucideIcons.mail, hint: 'you@example.com', keyboardType: TextInputType.emailAddress, ), @@ -189,7 +190,7 @@ class _ProfileState extends State { _field( label: 'Address', controller: _addressCtrl, - icon: Icons.location_on_outlined, + icon: LucideIcons.mapPin, hint: 'Your address', maxLines: 2, ), @@ -282,7 +283,7 @@ class _ProfileState extends State { ), child: !hasImage ? Icon( - Icons.person_rounded, + LucideIcons.user, color: ColorConstants.onSurfaceVariant, size: 52.sp, ) @@ -303,7 +304,7 @@ class _ProfileState extends State { ), ), child: Icon( - Icons.photo_camera_rounded, + LucideIcons.camera, color: ColorConstants.onPrimary, size: 15.sp, ), @@ -332,7 +333,7 @@ class _ProfileState extends State { label, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurface, letterSpacing: 0.2, fontFamily: FontConstants.fontFamily, @@ -346,7 +347,7 @@ class _ProfileState extends State { keyboardType: keyboardType, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: readOnly ? ColorConstants.onSurfaceVariant : ColorConstants.onSurface, @@ -357,7 +358,7 @@ class _ProfileState extends State { prefixText: prefix, prefixStyle: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -396,7 +397,7 @@ class _ProfileState extends State { ), suffixIcon: readOnly ? Icon( - Icons.lock_outline_rounded, + LucideIcons.lock, size: 16.sp, color: ColorConstants.secondaryText, ) diff --git a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart index f35a18f..ca54712 100644 --- a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart +++ b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:get/get.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -176,7 +177,7 @@ class _RiderRewardsPageState extends State { '${s.tier.label} status', style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, color: Colors.white, fontFamily: FontConstants.fontFamily, @@ -188,7 +189,7 @@ class _RiderRewardsPageState extends State { '$daysLeft ${daysLeft == 1 ? 'day' : 'days'} left', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.78), fontFamily: FontConstants.fontFamily, ), @@ -204,7 +205,7 @@ class _RiderRewardsPageState extends State { loading ? '—' : '${s.points}', style: TextStyle( fontSize: 30.sp, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, letterSpacing: -1, height: 1, color: Colors.white, @@ -216,7 +217,7 @@ class _RiderRewardsPageState extends State { 'points', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: 0.4, color: Colors.white.withValues(alpha: 0.78), fontFamily: FontConstants.fontFamily, @@ -243,7 +244,7 @@ class _RiderRewardsPageState extends State { '${s.nextTier!.label}', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -283,7 +284,9 @@ class _RiderRewardsPageState extends State { color: tier.index <= s.tier.index ? ColorConstants.primary : ColorConstants.borderStrong, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), ), ), SizedBox(height: 6.h), @@ -294,8 +297,8 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 10.5.sp, fontWeight: tier == s.tier - ? FontWeight.w800 - : FontWeight.w600, + ? FontWeight.w700 + : FontWeight.w500, color: tier == s.tier ? ColorConstants.primary : ColorConstants.secondaryText, @@ -306,7 +309,7 @@ class _RiderRewardsPageState extends State { '${tier.threshold}', style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -375,7 +378,7 @@ class _RiderRewardsPageState extends State { Divider(height: 1, color: ColorConstants.dividerColor), SizedBox(height: 16.h), _meter( - icon: Icons.schedule_rounded, + icon: LucideIcons.clock, label: 'Reached inside the ETA', hit: s.onTime, total: s.measured, @@ -383,7 +386,7 @@ class _RiderRewardsPageState extends State { ), SizedBox(height: 16.h), _meter( - icon: Icons.alt_route_rounded, + icon: LucideIcons.route, label: 'Rode the assigned route', hit: s.onRoute, total: s.measured, @@ -401,7 +404,7 @@ class _RiderRewardsPageState extends State { 'cleared both checks', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -410,7 +413,7 @@ class _RiderRewardsPageState extends State { SizedBox(width: 8.w), MilerButton( label: 'See stops', - icon: Icons.history_rounded, + icon: LucideIcons.history, color: ColorConstants.primary, height: ButtonSizes.compact, expand: false, @@ -430,7 +433,7 @@ class _RiderRewardsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.info_outline_rounded, + LucideIcons.info, size: 13.sp, color: ColorConstants.secondaryText, ), @@ -444,7 +447,7 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 11.5.sp, height: 1.4, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -477,7 +480,7 @@ class _RiderRewardsPageState extends State { child: Column( children: [ _rule( - icon: Icons.timer_rounded, + icon: LucideIcons.timer, title: 'Close a stop before its ETA', body: 'Points scale with the distance you rode to reach it. ' @@ -485,7 +488,7 @@ class _RiderRewardsPageState extends State { ), _ruleDivider(), _rule( - icon: Icons.alt_route_rounded, + icon: LucideIcons.route, title: 'Ride the route your hub assigned', body: 'Wandering well past the planned distance breaks the ' @@ -493,7 +496,7 @@ class _RiderRewardsPageState extends State { ), _ruleDivider(), _rule( - icon: Icons.verified_rounded, + icon: LucideIcons.badgeCheck, title: 'Clear both checks for a compliance point', body: 'A stop that makes its ETA and stays on route earns one ' @@ -502,7 +505,7 @@ class _RiderRewardsPageState extends State { ), _ruleDivider(), _rule( - icon: Icons.event_repeat_rounded, + icon: LucideIcons.calendarSync, title: 'Points reset each month', body: 'Your status for the next period is set by the points ' @@ -524,7 +527,7 @@ class _RiderRewardsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.storefront_rounded, + LucideIcons.store, size: 14.sp, color: ColorConstants.secondaryText, ), @@ -536,7 +539,7 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 11.5.sp, height: 1.45, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -552,7 +555,7 @@ class _RiderRewardsPageState extends State { text, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.8, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -562,7 +565,7 @@ class _RiderRewardsPageState extends State { TextStyle get _bodyStyle => TextStyle( fontSize: 12.5.sp, height: 1.45, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ); @@ -577,7 +580,7 @@ class _RiderRewardsPageState extends State { return Row( children: [ Icon( - Icons.pending_actions_rounded, + LucideIcons.clipboardClock, size: 22.sp, color: ColorConstants.secondaryText, ), @@ -624,7 +627,7 @@ class _RiderRewardsPageState extends State { '$score%', style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, letterSpacing: -0.5, height: 1, color: accent, @@ -636,7 +639,7 @@ class _RiderRewardsPageState extends State { 'SCORE', style: TextStyle( fontSize: 8.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.6, height: 1, color: accent, @@ -654,7 +657,7 @@ class _RiderRewardsPageState extends State { Row( children: [ Icon( - Icons.workspace_premium_rounded, + LucideIcons.award, size: 17.sp, color: ColorConstants.acceptGreen, ), @@ -667,7 +670,7 @@ class _RiderRewardsPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -684,7 +687,7 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 11.5.sp, height: 1.35, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -725,7 +728,7 @@ class _RiderRewardsPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -736,7 +739,7 @@ class _RiderRewardsPageState extends State { '$hit/$total · $percent%', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: accent, fontFamily: FontConstants.fontFamily, @@ -753,7 +756,9 @@ class _RiderRewardsPageState extends State { child: Stack( children: [ ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), child: LinearProgressIndicator( value: total == 0 ? 0 : hit / total, minHeight: 8.h, @@ -805,7 +810,7 @@ class _RiderRewardsPageState extends State { title, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/profile/informations/saved_address.dart b/lib/views/Dashboard/profile/informations/saved_address.dart index 290251b..fa0ef17 100644 --- a/lib/views/Dashboard/profile/informations/saved_address.dart +++ b/lib/views/Dashboard/profile/informations/saved_address.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -116,7 +117,7 @@ class _SavedAddressPageState extends State { 'Edit', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -172,7 +173,7 @@ class _SavedAddressPageState extends State { shape: BoxShape.circle, ), child: Icon( - Icons.home_outlined, + LucideIcons.house, color: ColorConstants.primary, size: 22.sp, ), @@ -186,7 +187,7 @@ class _SavedAddressPageState extends State { 'Home address', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -209,7 +210,7 @@ class _SavedAddressPageState extends State { SizedBox(height: 14.h), MilerButton( label: hasAddress ? 'Change address' : 'Add your address', - icon: hasAddress ? Icons.edit_outlined : Icons.add_rounded, + icon: hasAddress ? LucideIcons.pencil : LucideIcons.plus, variant: MilerButtonVariant.tonal, height: ButtonSizes.secondary, onPressed: _startEditing, @@ -232,7 +233,7 @@ class _SavedAddressPageState extends State { 'Home address', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -248,7 +249,7 @@ class _SavedAddressPageState extends State { style: TextStyle( fontSize: 15.sp, height: 1.45, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -297,7 +298,7 @@ class _SavedAddressPageState extends State { 'Cancel', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/summary/summary.dart b/lib/views/Dashboard/summary/summary.dart index 11750e4..851dcf7 100644 --- a/lib/views/Dashboard/summary/summary.dart +++ b/lib/views/Dashboard/summary/summary.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:get/get.dart'; import 'package:lottie/lottie.dart' hide Marker; import 'package:fl_chart/fl_chart.dart'; @@ -169,7 +171,7 @@ class _SummaryState extends State { _periods[index], style: TextStyle( fontSize: 13.sp, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected ? Colors.white : ColorConstants.onSurfaceVariant, @@ -238,12 +240,16 @@ class _SummaryState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_periods[_selectedPeriod].toUpperCase()} · PICKUPS/DELIVERIES', + // `WEEK · PICKUPS/DELIVERIES` was a slug, not a heading — + // a slash between two nouns is a database field name. The + // rider is reading his own week. + '${_periods[_selectedPeriod]} · ' + '${ServiceProfile.active.jobNounPlural}', style: TextStyle( - color: Colors.white.withValues(alpha: 0.8), - fontSize: 11.sp, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, + color: Colors.white.withValues(alpha: 0.85), + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, fontFamily: FontConstants.fontFamily, ), ), @@ -300,7 +306,7 @@ class _SummaryState extends State { child: _buildHeroMetric( '$_successRate%', 'Success rate', - icon: Icons.percent_rounded, + icon: LucideIcons.percent, ), ), SizedBox(width: 12.w), @@ -308,7 +314,7 @@ class _SummaryState extends State { child: _buildHeroMetric( '$cancelled', 'Cancelled', - icon: Icons.do_not_disturb_on_rounded, + icon: LucideIcons.circleMinus, ), ), SizedBox(width: 12.w), @@ -316,7 +322,7 @@ class _SummaryState extends State { child: _buildHeroMetric( '$points', 'Reward pts', - icon: Icons.stars_rounded, + icon: LucideIcons.star, ), ), ], @@ -348,29 +354,28 @@ class _SummaryState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (icon != null) ...[ - Icon( - icon, - size: 14.sp, - color: Colors.white.withValues(alpha: 0.85), - ), + Icon(icon, size: 16.sp, color: Colors.white.withValues(alpha: 0.9)), SizedBox(height: 6.h), ], Text( value, style: TextStyle( color: Colors.white, - fontSize: 15.sp, - fontWeight: FontWeight.w800, + fontSize: 17.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, ), ), - SizedBox(height: 2.h), + SizedBox(height: 3.h), Text( label, style: TextStyle( - color: Colors.white.withValues(alpha: 0.65), - fontSize: 10.sp, - fontWeight: FontWeight.w500, + // 10 -> 12, and up from w500. A label at 65% white on maroon at + // ten points is decoration; the tile is worthless without it. + color: Colors.white.withValues(alpha: 0.78), + fontSize: 12.sp, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -414,134 +419,90 @@ class _SummaryState extends State { 'LIFETIME', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 1.0, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), - // IntrinsicHeight so the three cards match height whatever their - // figures. `CrossAxisAlignment.stretch` alone would ask for infinite - // height inside this scroll view — the trap pinned by - // `card_stripe_layout_test.dart`. - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: _statCard( - icon: Icons.check_circle_rounded, - value: '$total', - label: 'Completed', - accent: ColorConstants.acceptGreen, + // ── A ruled band, not three cards ── + // + // These were three shadowed white cards, each with a tinted icon disc + // — the heaviest stat idiom left in the app, sitting directly under a + // brand hero that already carries its own three figures. Account's + // stats band set the system's shape for exactly this content: figures + // separated by hairlines on one quiet fill, no discs, no shadows. + // Same three facts, a quarter of the ink — and the band no longer + // competes with the hero above it for the reader's first look. + Container( + decoration: BoxDecoration( + color: ColorConstants.cardSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + padding: EdgeInsets.symmetric(vertical: 16.h), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded(child: _statFigure('$total', 'Completed')), + _statRule(), + Expanded(child: _statFigure('$cancelled', 'Fell through')), + _statRule(), + Expanded( + child: _statFigure( + attempts > 0 ? '$rate%' : '\u2014', + 'Success', + ), ), - ), - SizedBox(width: 10.w), - Expanded( - child: _statCard( - // Filled, like the other two. This was the one *outlined* - // glyph in a row of three, so beside a solid tick it read as - // lighter and slightly unfinished — a set of icons has to - // agree on fill weight before it agrees on anything else. - icon: Icons.do_not_disturb_on_rounded, - value: '$cancelled', - label: 'Fell through', - // Grey, not red. A stop that fell through is usually a shut - // shop or an absent customer — a fact about the day's - // logistics, not the rider's fault — and this is the screen - // where he checks his earnings, not where he gets told off. - accent: ColorConstants.secondaryText, - ), - ), - SizedBox(width: 10.w), - Expanded( - child: _statCard( - // A percentage, not a trend. `trending_up` promises movement - // over time — an arrow climbing — and this is a ratio of two - // lifetime totals that mostly sits still. The glyph should - // say what the number is. - icon: Icons.percent_rounded, - value: attempts > 0 ? '$rate%' : '—', - label: 'Success', - accent: ColorConstants.acceptGreen, - ), - ), - ], + ], + ), ), ), ], ); } - /// One stat card. Mirrors `TripCard`'s metric card on Home, deliberately. - Widget _statCard({ - required IconData icon, - required String value, - required String label, - required Color accent, - }) { - return Container( - padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 6.w), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - boxShadow: const [ - BoxShadow( - color: Color(0x0F000000), - blurRadius: 14, - offset: Offset(0, 5), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 36.w, - height: 36.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.10), - shape: BoxShape.circle, - ), - child: Icon(icon, size: 18.sp, color: accent), - ), - SizedBox(height: 11.h), - // Fixed height so a four-digit total scales down inside its own card - // rather than making one of the three taller than the other two. - SizedBox( - height: 24.h, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - value, - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.w800, - letterSpacing: -0.6, - height: 1.05, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ), - SizedBox(height: 4.h), - Text( - label.toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Widget _statRule() => VerticalDivider( + width: 1, + thickness: 1, + indent: 6.h, + endIndent: 6.h, + color: ColorConstants.borderSubtle, + ); + + /// One figure in the lifetime band. Monochrome on purpose: these are + /// lifetime ledger facts, and the day's colour belongs to the hero above. + Widget _statFigure(String value, String label) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value, style: TextStyle( - fontSize: 9.5.sp, - fontWeight: FontWeight.w800, - letterSpacing: 0.7, - color: ColorConstants.secondaryText, + fontSize: 20.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + height: 1.05, + color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), - ], - ), + ), + SizedBox(height: 4.h), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], ); } } @@ -607,8 +568,10 @@ class _TotalDistanceCardState extends State { /// looking at the debug build is the one deciding whether the feature works. /// The empty state says "No distance recorded yet", which is true and is what /// makes a silent endpoint visible. - Map _fallbackWeeklyKms() => - {'details': const [], 'total_kms': 0.0}; + Map _fallbackWeeklyKms() => { + 'details': const [], + 'total_kms': 0.0, + }; String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3); @@ -641,7 +604,7 @@ class _TotalDistanceCardState extends State { // kilometres covered, not how fast he went. A route glyph is // both accurate and the one every delivery app uses for distance. Icon( - Icons.route_rounded, + LucideIcons.route, color: ColorConstants.primary, size: 20.sp, ), @@ -656,7 +619,7 @@ class _TotalDistanceCardState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -785,7 +748,7 @@ class _TotalDistanceCardState extends State { '${rod.toY.toStringAsFixed(1)} km', TextStyle( color: Colors.white, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontSize: 12.sp, fontFamily: FontConstants.fontFamily, ), @@ -836,7 +799,7 @@ class _TotalDistanceCardState extends State { style: TextStyle( fontSize: 11.sp, fontWeight: isTouched - ? FontWeight.w800 + ? FontWeight.w700 : FontWeight.w500, color: isTouched ? ColorConstants.primary @@ -876,23 +839,32 @@ class _TotalDistanceCardState extends State { ), ), SizedBox(height: 12.h), + // ── Monochrome, like every other figure row ── + // + // These were amber / brand-red / blue — three hues for three ledger + // facts, on the one page family where colour is reserved for the hero + // and the chart's own bars. And "Best Day" rendered blank when the + // week's data carries no day names, leaving a caption with nothing + // above it; an absent fact prints the em dash like everywhere else. Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildChartStat( - 'Best Day', - _shortDay(details[bestIdx].day), - ColorConstants.warning, + 'Best day', + _shortDay(details[bestIdx].day).trim().isEmpty + ? '\u2014' + : _shortDay(details[bestIdx].day), + ColorConstants.slateText, ), _buildChartStat( 'Total', '${totalKms.toStringAsFixed(1)} km', - ColorConstants.primary, + ColorConstants.slateText, ), _buildChartStat( - 'Avg/Day', + 'Avg/day', '${avgPerDay.toStringAsFixed(1)} km', - ColorConstants.tertiary, + ColorConstants.slateText, ), ], ), @@ -907,7 +879,7 @@ class _TotalDistanceCardState extends State { value, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: color, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/helpers/constants/Colorconstants.dart b/lib/views/helpers/constants/Colorconstants.dart index 4dbf154..072f372 100644 --- a/lib/views/helpers/constants/Colorconstants.dart +++ b/lib/views/helpers/constants/Colorconstants.dart @@ -75,7 +75,12 @@ class ColorConstants { /// app washed out in exactly the daylight this app is used in. /// /// Now 5.48:1 behind white, and ≥4.5:1 on all four surfaces the app draws on. - static const Color acceptGreen = Color(0xFF047857); + // #047857 → #047354 (2026-08-21). A two-percent darkening, and the only + // reason for it is arithmetic: with the canvas moved to #DEE3EA the old + // value measured 4.25:1 on it — "AA large only", which a 13.5sp status word + // is not. This clears 4.54 on the canvas and 5.86 on white, and at this + // distance the hue is indistinguishable from what it replaced. + static const Color acceptGreen = Color(0xFF047354); /// Kept as a name for the cash figure, now that [acceptGreen] is dark enough /// to be read as text. Same value; the two greens collapsed into one. @@ -95,7 +100,10 @@ class ColorConstants { // `cardSurface`** — and the card fill is where warnings actually appear, on // the skip sheet and the short-payment notice. A token that passes on the // surface nobody uses it on has not passed. - static const Color warning = Color(0xFFAE5008); + // #AE5008 → #A44B08, for the same arithmetic reason as [acceptGreen]: 4.12 + // on the new canvas was below AA for the size this is actually set at. + // Now 4.54 on the canvas, 5.85 on white. + static const Color warning = Color(0xFFA44B08); static const Color onWarning = Color(0xFFFFFFFF); static const Color warningContainer = Color(0xFFFEF3C7); @@ -163,6 +171,21 @@ class ColorConstants { static const Color deliveryChip = Color(0xFF1E40AF); static const Color combinedChip = Color(0xFF4C1D95); + // ── The service line ───────────────────────────────────────────────── + // + // A tenant whose riders run subscription meal routes sees one work type all + // day — collect a crate from a kitchen, drop it to subscribers — so its stops + // wear a single `SERVICE` mark instead of the pickup/delivery pair. See + // `ServiceProfile.workTypeLabel`. + // + // Deliberately the delivery blue rather than a fourth hue: a service day is + // delivery-shaped from the rider's side, the blue is already tuned to 5.17:1, + // and nothing in that day sits next to a Doormile stop for the two to be + // confused. Named separately so it can diverge later without a hunt through + // call sites for which blue meant which thing. + static const Color serviceAccent = deliveryAccent; + static const Color serviceChip = deliveryChip; + /// Text drawn *on* a chip fill. static const Color onChip = Color(0xFFFFFFFF); @@ -235,10 +258,60 @@ class ColorConstants { /// rather than as a translucent rectangle. static const Color glassRim = Color(0x8CFFFFFF); + /// ── The pane a list card is made of ── + /// + /// Every card the rider scrolls — a stop on Home, a job on Deliveries — is + /// this fill with a soft, wide shadow under it ([DesignConstants.shadowGlass]) + /// and **no border at all**. + /// + /// Not a solid white: at 92% the page's slate tint comes through just enough + /// that the card reads as a pane laid over the screen rather than a white + /// rectangle cut into it, which is the whole difference between "glass" and + /// "box". It stays high enough for near-black type at any brightness — a + /// rider reads addresses off this surface in direct sun. + /// + /// No `BackdropFilter`. A real blur re-samples everything behind it every + /// frame, and a scrolling list of twenty of them is the single most reliable + /// way to drop a mid-range Android phone to 30fps. The blur is reserved for + /// the two surfaces that genuinely sit over live content — the app bar and + /// the bottom sheets ([milerGlassSheet]). + static const Color glassCard = Color(0xEBFFFFFF); + + /// The same pane, warmed by the brand, for the one stop that is under way. + /// + /// A whisper — 5% — because a card the rider has to *read* is the wrong place + /// for a saturated fill: what marks the live stop is the LIVE chip and the + /// button on it, not a red panel behind its address. + static const Color glassCardLive = Color(0x0D960019); + // ── Daylight surfaces ──────────────────────────────────────────────── // Tuned for a phone clamped to a handlebar in direct sun. Slate-tinted grey // separates from white far more strongly outdoors than the old warm surface. - static const Color daylightSurface = Color(0xFFF8FAFC); + // + // #F8FAFC → #EEF2F7 (2026-08-21). The old value was a four-percent step off + // the cards' white — honest on a calibrated display and INVISIBLE on the + // budget LCD this app actually ships to: verified on-device, the record's + // panels and the queue's cards read as printed on the same sheet. A ground + // has one job — make the surfaces on it read as surfaces — and a step nobody + // can see is a step not taken. Secondary text keeps ≥4.2:1 here. + // ── #EEF2F7 → #DEE3EA (2026-08-21, measured) ── + // + // The same correction, made once more and this time with a number attached. + // The previous move went from a 4% step to a 12.4% one, which was better and + // still not enough: **white on this ground measured 1.124:1**. A tonal step + // does not begin to read as a *different surface* until roughly 1.25:1, and + // outdoors on a cheap LCD it needs the top of that band — which is why every + // screen that wanted its content to separate had quietly grown a border or a + // shadow to do the ground's job for it. + // + // #DEE3EA puts white at **1.290:1**. Surfaces now separate on their own, so + // the borders and shadows those screens added can come off rather than being + // piled higher. Body text still clears 13.8:1 here and secondary text 4.4:1, + // so nothing was traded for it. + // + // This is [MilerSurface.canvas]. Read that ladder, not this constant, when + // choosing a ground — it is what makes four layers four layers. + static const Color daylightSurface = Color(0xFFDEE3EA); /// The app's near-black body colour. static const Color slateText = Color(0xFF0F172A); diff --git a/lib/views/helpers/constants/Font_constant.dart b/lib/views/helpers/constants/Font_constant.dart index 0296ec2..175d3ec 100644 --- a/lib/views/helpers/constants/Font_constant.dart +++ b/lib/views/helpers/constants/Font_constant.dart @@ -1,11 +1,43 @@ import 'package:flutter/material.dart'; class FontConstants { - // Miler Design System: Manrope typography (variable font) — a free, - // OFL-licensed geometric sans chosen as the closest legal stand-in for - // Uber Move (which is proprietary and cannot be bundled). - static const String fontFamily = 'Manrope'; - static String fontFamilyFallback = 'PlusJakartaSans'; + /// ── The app's typeface ── + /// + /// Poppins: geometric, OFL-licensed, and the one every screen now reads from. + /// It replaced Manrope, which was itself a stand-in for Uber Move. + /// + /// **Read this before changing it.** The family was already declared in + /// `pubspec.yaml` as `poppins`, with a lowercase path on every one of its + /// seven assets, while the files on disk are `assets/fonts/Poppins/Poppins-*`. + /// Asset bundling is case-sensitive on both platforms, so not one of them + /// resolved: anything asking for the family fell back to Roboto and no error + /// was raised anywhere. The declaration is fixed and all seven static weights + /// are registered — Poppins ships as separate files rather than a variable + /// font, and without explicit 800/900 entries Flutter synthesises them by + /// smearing SemiBold, which is what makes a heading look muddy at exactly the + /// size it is meant to be read from. + /// ── The weight scale is calibrated for THIS family ── + /// + /// Poppins runs optically heavier than Manrope at every grade: it is + /// geometric, its bowls are wide and its stems thick, so `w800` here is far + /// darker on the page than `w800` was in a variable Manrope. The whole scale + /// was tuned against Manrope and came across a grade too heavy — headings + /// read as shouting and body copy as bold. + /// + /// Every weight in `lib/` (outside `lib/xpress`, which is Proxima Nova) was + /// stepped down one grade: 900→800, 800→700, 700→600, 600→500. The *relative* + /// hierarchy is untouched — each level is still one step from its neighbour — + /// so nothing lost its rank; the whole run just sits where it should for this + /// typeface. + /// + /// **If the family changes again, re-tune this.** A weight is a number, not a + /// design: what it looks like belongs to the font. + static const String fontFamily = 'Poppins'; + + /// Only ever reached for a glyph Poppins does not carry. Manrope covers the + /// Latin and currency sets the app uses, so this is a safety net rather than + /// a second typeface in the design. + static String fontFamilyFallback = 'Manrope'; // Typography scale static const double displayLg = 32.0; @@ -18,21 +50,21 @@ class FontConstants { static TextStyle displayLgStyle = const TextStyle( fontSize: 32, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.64, fontFamily: fontFamily, ); static TextStyle headlineMdStyle = const TextStyle( fontSize: 24, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.24, fontFamily: fontFamily, ); static TextStyle headlineSmStyle = const TextStyle( fontSize: 20, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, letterSpacing: -0.2, fontFamily: fontFamily, ); @@ -51,7 +83,7 @@ class FontConstants { static TextStyle labelBoldStyle = const TextStyle( fontSize: 14, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: fontFamily, ); diff --git a/lib/views/helpers/constants/app_theme.dart b/lib/views/helpers/constants/app_theme.dart index 1fff3ad..3416e0e 100644 --- a/lib/views/helpers/constants/app_theme.dart +++ b/lib/views/helpers/constants/app_theme.dart @@ -36,7 +36,13 @@ class AppTheme { }, ), fontFamily: FontConstants.fontFamily, - fontFamilyFallback: const ['Roboto', 'sans-serif'], + // Manrope first: it is bundled, so its coverage is guaranteed on every + // device, and it is what backs Poppins up for anything Poppins does not + // carry. The platform families behind it are the last resort. Poppins is + // missing U+2192 among others — a glyph the app no longer renders as a + // character, but the net belongs here rather than in a rule nobody + // remembers. + fontFamilyFallback: const ['Manrope', 'Roboto', 'sans-serif'], textTheme: ThemeData.light().textTheme.apply( fontFamily: FontConstants.fontFamily, ), @@ -110,7 +116,7 @@ class AppTheme { ), textStyle: const TextStyle( fontSize: 16, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -147,7 +153,7 @@ class AppTheme { ), textStyle: const TextStyle( fontSize: 15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -156,7 +162,7 @@ class AppTheme { style: TextButton.styleFrom( foregroundColor: _primary, textStyle: const TextStyle( - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/views/helpers/constants/design_constants.dart b/lib/views/helpers/constants/design_constants.dart index 33c5481..1829b56 100644 --- a/lib/views/helpers/constants/design_constants.dart +++ b/lib/views/helpers/constants/design_constants.dart @@ -97,6 +97,31 @@ class DesignConstants { static const List shadowLg = [ BoxShadow(color: Color(0x2A000000), blurRadius: 12, offset: Offset(0, 8)), ]; + + /// A surface that floats **over** the list rather than in it: the selection + /// bar, a snackbar. Wider and deeper than [shadowGlass] because it has to + /// read as being on a different plane from the cards it covers — but still + /// slate-tinted and still soft, because a hard drop shadow under a white bar + /// reads as a grey stripe, which is exactly what it looked like. + static const List shadowFloat = [ + BoxShadow(color: Color(0x1F0F172A), blurRadius: 28, offset: Offset(0, 12)), + BoxShadow(color: Color(0x140F172A), blurRadius: 4, offset: Offset(0, 2)), + ]; + + /// ── What holds a borderless card off the page ── + /// + /// Two shadows, not one, and that is the whole trick: a wide soft pool gives + /// the card its lift, and a tight near-opaque line under the bottom edge + /// gives it a *defined* edge — without it a single blurred shadow reads as a + /// smudge and the card looks unfinished rather than raised. + /// + /// Tinted with the page's slate rather than pure black: a neutral-black + /// shadow on a blue-grey ground goes muddy, which is what makes a card look + /// cheap next to the same card in a design file. + static const List shadowGlass = [ + BoxShadow(color: Color(0x0F0F172A), blurRadius: 18, offset: Offset(0, 6)), + BoxShadow(color: Color(0x0A0F172A), blurRadius: 2, offset: Offset(0, 1)), + ]; } /// ───────────────────────────────────────────────────────────────────────── diff --git a/lib/views/helpers/constants/miler_surface.dart b/lib/views/helpers/constants/miler_surface.dart new file mode 100644 index 0000000..9b2857b --- /dev/null +++ b/lib/views/helpers/constants/miler_surface.dart @@ -0,0 +1,150 @@ +import 'package:flutter/widgets.dart'; + +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// FOUR LAYERS, AND WHAT EACH ONE MEANS +/// +/// The app had **five** near-identical light surfaces — `pureSurface`, +/// `surface`, `neutralLight`, `cardSurface`, `daylightSurface` — and measured +/// against the page ground, three of them were invisible: +/// +/// ``` +/// neutralLight #F2F4F8 1.021 : 1 ← indistinguishable +/// cardSurface #EDEFF4 1.023 : 1 ← indistinguishable +/// surface #FCF9F8 1.073 : 1 ← and warm, unlike everything else +/// pureSurface #FFFFFF 1.124 : 1 ← the one that mattered, and too close +/// ``` +/// +/// Five tokens doing the work of two is why the app looked flat *and* why +/// individual screens grew their own borders and shadows: a surface that will +/// not separate on its own has to be outlined, and once one screen outlines +/// something every screen does, in its own way. +/// +/// The fix was not more containers. It was giving the ground somewhere to be: +/// the canvas moved down to #DEE3EA, white now separates at 1.290:1, and the +/// layers below are named for **what a surface means** rather than for what +/// colour it happens to be. +/// +/// ── The ladder ── +/// +/// ``` +/// 0 canvas the page itself. Never white. +/// 1 working a region the rider reads or works in. White, no border. +/// 2 raised something that acts: a command bar, a live card. Shadow. +/// 3 floating over everything, with a scrim: sheets, dialogs. +/// ``` +/// +/// A container is justified when it is one of these layers. It is not +/// justified because the content inside it is a group — grouping is done by +/// whitespace, type and the timeline, which is why a route reads as a route +/// without a box around each stop. +/// ───────────────────────────────────────────────────────────────────────── +abstract final class MilerSurface { + /// **Layer 0.** The page. Every scaffold in the app sits on this. + /// + /// Deliberately not white: a canvas that is white leaves nothing for a white + /// surface to be distinguishable *from*, which is the state this app was in. + static const Color canvas = ColorConstants.daylightSurface; + + /// **Layer 1.** A region the rider reads or works in — a sheet's body, a + /// details page, a grouped operational block. + /// + /// No border. At 1.290:1 against [canvas] it separates on its own, and an + /// outline on top of a step that already works is a line to read past. + static const Color working = ColorConstants.pureSurface; + + /// **Layer 2.** Something that *acts*: the floating command bar, the live + /// card, a control that owns the bottom of the screen. + /// + /// Same fill as [working] — the difference is [raisedShadow], because what + /// separates an action surface from a reading surface is that it appears to + /// sit above the page rather than in it. + static const Color raised = ColorConstants.pureSurface; + + /// **Layer 3.** Over everything, with a scrim beneath: modal sheets and + /// dialogs. Painted by `milerGlassSheet`; this names the layer. + static const Color floating = ColorConstants.glassCard; + + /// A quiet tonal block *inside* layer 1 — a fact grid, a folded group. + /// + /// The only nesting the system allows, and only when the block is a + /// different **kind** of thing from what surrounds it. Never for grouping. + static const Color inset = ColorConstants.neutralLight; + + /// Layer 2's lift. One shadow, used everywhere something is raised, so + /// "floating" looks the same on every screen. + static const List raisedShadow = DesignConstants.shadowSm; + + /// Layer 3's lift. + static const List floatingShadow = DesignConstants.shadowFloat; + + /// The dim behind a layer-3 surface. Separation comes from the scrim first + /// and the shadow second — a sheet that needs a heavy shadow to be legible + /// is a sheet whose scrim is too weak. + static const Color scrim = Color(0x66000000); + + /// The gutter a layer-1 panel leaves around itself, so the canvas reads as + /// ground rather than as a hairline. One number, because two panels a few + /// points apart is how a page stops looking composed. + static const double panelGutter = 12; + + /// The breathing room *inside* a layer-1 panel. + static const double panelPad = 14; +} + +/// ── Layer 1, as a widget ── +/// +/// The ladder above says a working region is *white, standing on the canvas, +/// with no border*. Home was the screen that proved why that has to be a +/// widget rather than a paragraph: its sheet was painted with the canvas and +/// its content placed straight onto it, so the whole page came out one flat +/// grey with nothing white anywhere on it. Every rule was followed except the +/// one that does the work — something has to actually stand on the ground. +/// +/// No border and no shadow, deliberately. White separates from the canvas at +/// 1.290 : 1, which is above the ~1.25 : 1 where two tones start reading as +/// two surfaces, so an outline here would be a line to read past rather than +/// a line that says anything. +class MilerPanel extends StatelessWidget { + final Widget child; + + /// Space below this panel before the next one. The canvas showing through + /// the gap is what makes them read as separate regions. + final double gap; + + /// Set false where the child already owns its own horizontal padding — a + /// list whose rows need to bleed to the panel edge, for instance. + final bool padded; + + const MilerPanel({ + super.key, + required this.child, + this.gap = 10, + this.padded = true, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.fromLTRB( + MilerSurface.panelGutter, + 0, + MilerSurface.panelGutter, + gap, + ), + padding: padded + ? EdgeInsets.symmetric( + horizontal: MilerSurface.panelPad, + vertical: MilerSurface.panelPad, + ) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: MilerSurface.working, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: child, + ); + } +} diff --git a/lib/views/helpers/constants/miler_type.dart b/lib/views/helpers/constants/miler_type.dart index da46820..33f23fb 100644 --- a/lib/views/helpers/constants/miler_type.dart +++ b/lib/views/helpers/constants/miler_type.dart @@ -64,7 +64,7 @@ class MilerType { /// The one thing a screen is about. One per screen, or none. static TextStyle get hero => TextStyle( fontSize: 28.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.9, height: 1.08, color: ColorConstants.slateText, @@ -74,7 +74,7 @@ class MilerType { /// App bar titles and sheet headings. static TextStyle get pageTitle => TextStyle( fontSize: 22.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.6, height: 1.15, color: ColorConstants.slateText, @@ -84,7 +84,7 @@ class MilerType { /// A stop name in a list, a section heading inside a screen. static TextStyle get cardTitle => TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.4, height: 1.2, color: ColorConstants.slateText, @@ -94,7 +94,7 @@ class MilerType { /// Addresses, running copy, the title line of a compact row. static TextStyle get body => TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, letterSpacing: -0.2, height: 1.35, color: ColorConstants.slateText, @@ -114,7 +114,7 @@ class MilerType { /// Metadata, timestamps, chip text, the second line of a row. static TextStyle get label => TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, height: 1.35, color: ColorConstants.secondaryText, fontFamily: _family, @@ -124,7 +124,7 @@ class MilerType { /// expected to be read outdoors. static TextStyle get micro => TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, height: 1.3, color: ColorConstants.secondaryText, fontFamily: _family, @@ -134,7 +134,7 @@ class MilerType { /// the reason most groups in this app need no outline. static TextStyle get eyebrow => TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 1.2, height: 1.3, color: ColorConstants.secondaryText, @@ -150,7 +150,7 @@ class MilerType { /// number, resolved without `.sp`. static const TextStyle pageTitleFixed = TextStyle( fontSize: 22, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.6, height: 1.15, color: ColorConstants.slateText, @@ -165,7 +165,7 @@ class MilerType { /// them lines up. static TextStyle figure(double size, {Color? color}) => TextStyle( fontSize: size.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, height: 1.1, fontFeatures: const [FontFeature.tabularFigures()], @@ -176,8 +176,14 @@ class MilerType { /// The label inside a button. Sized against the button's own height, so a /// compact button does not shout like a primary one. static TextStyle buttonLabel(double height) => TextStyle( - fontSize: (height >= 56 ? 16 : height >= 48 ? 15 : 14).sp, - fontWeight: FontWeight.w700, + fontSize: + (height >= 56 + ? 16 + : height >= 48 + ? 15 + : 14) + .sp, + fontWeight: FontWeight.w600, letterSpacing: -0.1, color: ColorConstants.onAccent, fontFamily: _family, @@ -192,7 +198,7 @@ class MilerType { extension MilerTypeTint on TextStyle { TextStyle on(Color color) => copyWith(color: color); - TextStyle get bold => copyWith(fontWeight: FontWeight.w800); + TextStyle get bold => copyWith(fontWeight: FontWeight.w700); - TextStyle get semibold => copyWith(fontWeight: FontWeight.w700); + TextStyle get semibold => copyWith(fontWeight: FontWeight.w600); } diff --git a/lib/views/helpers/widgets/app_widgets.dart b/lib/views/helpers/widgets/app_widgets.dart index 8f50338..fbe4f05 100644 --- a/lib/views/helpers/widgets/app_widgets.dart +++ b/lib/views/helpers/widgets/app_widgets.dart @@ -2,10 +2,13 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:shimmer/shimmer.dart'; import 'miler_app_bar.dart' show milerGlassSurface; +import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import '../constants/Colorconstants.dart'; @@ -114,11 +117,28 @@ class PrimaryButton extends StatelessWidget { height: height, child: ElevatedButton( onPressed: loading ? null : onPressed, + // ── A blocked primary goes neutral, it does not go pale ── + // + // This drew the brand at 50% with its label at 80% white: still a + // saturated pill, still shaped like the thing you press, just dimmer. + // A rider reads that as the button and taps it, and nothing happens — + // which is the exact failure `stop_verify_chrome_test` pins on the + // other screen that has a blocked step, where the fix was to draw no + // coloured button at all. + // + // Neutral fill with the page's secondary ink says *not yet* instead of + // *try me*, and it clears AA at 5.45:1 where white-on-washed-red did + // not come close. `loading` keeps the brand, because a button mid-work + // is disabled for a different reason and must not look unavailable. style: ElevatedButton.styleFrom( backgroundColor: color, foregroundColor: foreground, - disabledBackgroundColor: color.withValues(alpha: 0.5), - disabledForegroundColor: foreground.withValues(alpha: 0.8), + disabledBackgroundColor: loading + ? color + : ColorConstants.neutralLight, + disabledForegroundColor: loading + ? foreground + : ColorConstants.secondaryText, elevation: 0, minimumSize: Size(double.infinity, height), shape: RoundedRectangleBorder( @@ -149,7 +169,7 @@ class PrimaryButton extends StatelessWidget { maxLines: 1, style: const TextStyle( fontSize: 16, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -212,7 +232,7 @@ class SecondaryButton extends StatelessWidget { style: TextStyle( color: c, fontSize: 14, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -258,7 +278,7 @@ class StatusChip extends StatelessWidget { style: TextStyle( color: color, fontSize: 12, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontFamily: FontConstants.fontFamily, ), ), @@ -328,7 +348,7 @@ class SectionHeader extends StatelessWidget { maxLines: 1, style: TextStyle( fontSize: 18, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, letterSpacing: -0.2, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, @@ -344,7 +364,7 @@ class SectionHeader extends StatelessWidget { action!, style: TextStyle( fontSize: 14, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -404,7 +424,7 @@ class InfoBanner extends StatelessWidget { const InfoBanner({ super.key, required this.text, - this.icon = Icons.info_outline_rounded, + this.icon = LucideIcons.info, Color? color, }) : _colorOverride = color; @@ -463,6 +483,15 @@ class MilerEmptyState extends StatelessWidget { /// different sentences — but the chrome is one thing now. final Widget? illustration; + /// Edge of the square the [illustration] is drawn in. + /// + /// 180 is right for a screen whose empty state *is* the screen — an empty + /// booking queue on a fresh install. It is too much for an empty state that + /// is a label on one branch of a populated tab: on Activity the animation was + /// the largest object in the app on the one screen with nothing to say, and + /// it pushed the sentence answering the rider's question toward the fold. + final double illustrationSize; + /// Anything that belongs under the CTA: a live-listening indicator, a debug /// line. Kept as a slot so a screen does not have to abandon the component to /// add one row. @@ -490,6 +519,7 @@ class MilerEmptyState extends StatelessWidget { this.actionLabel, this.onAction, this.illustration, + this.illustrationSize = 180, this.footer, }) : _accentOverride = accent; @@ -505,36 +535,40 @@ class MilerEmptyState extends StatelessWidget { // sizing and the spacing below it stay the component's, so two // screens with different animations still read as one system. if (illustration != null) - SizedBox(width: 180, height: 180, child: illustration) + SizedBox( + width: illustrationSize, + height: illustrationSize, + child: illustration, + ) else - // Layered halo: a faint outer ring + a slightly stronger inner - // disc holding the icon. - Stack( - alignment: Alignment.center, - children: [ - Container( - width: 120, - height: 120, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.05), - shape: BoxShape.circle, + // Layered halo: a faint outer ring + a slightly stronger inner + // disc holding the icon. + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.05), + shape: BoxShape.circle, + ), ), - ), - Container( - width: 84, - height: 84, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.10), - shape: BoxShape.circle, + Container( + width: 84, + height: 84, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 40, + color: accent.withValues(alpha: 0.75), + ), ), - child: Icon( - icon, - size: 40, - color: accent.withValues(alpha: 0.75), - ), - ), - ], - ), + ], + ), const SizedBox(height: 24), Text( title, @@ -719,7 +753,8 @@ class _TopBarClose extends StatelessWidget { height: 48, child: Align( alignment: Alignment.centerLeft, - child: Icon(icon, size: 24, color: ColorConstants.slateText), + // On the brand ground — see [milerPageBar]. + child: Icon(icon, size: 24, color: Colors.white), ), ), ), @@ -758,19 +793,33 @@ PreferredSizeWidget milerPageBar( /// presented modally. Only this gets the cross. bool isModal = false, }) { - final IconData backIcon = isModal - ? Icons.close_rounded - : Icons.arrow_back_rounded; + final IconData backIcon = isModal ? LucideIcons.x : LucideIcons.arrowLeft; final bool hasControlRow = onBack != null || (actions?.isNotEmpty ?? false); return AppBar( - // The frosted brand wash every bar in the app shares — see - // [milerGlassSurface]. Replaces the flat white, so a pushed page and a tab - // read as the same surface at the top. - backgroundColor: Colors.transparent, - flexibleSpace: milerGlassSurface(), + // ── One ground, every bar in the app ── + // + // This wore the 7% frosted brand wash flattened against white while Home + // and the four tabs are solid brand. So pushing Edit Profile, Saved + // Addresses, Alert Sound or Notifications changed the colour of the top of + // the phone AND the colour of the status-bar glyphs, which reads as leaving + // the app rather than opening a page inside it. + // + // Solid [ColorConstants.primary], and everything on it inverts. The wash + // survives as `milerGlassSurface()` for the sheets, which sit over content + // and still want it. + backgroundColor: ColorConstants.primary, surfaceTintColor: Colors.transparent, - foregroundColor: ColorConstants.slateText, + foregroundColor: Colors.white, + iconTheme: const IconThemeData(color: Colors.white), + actionsIconTheme: const IconThemeData(color: Colors.white), + // Dark glyphs on maroon is the one contrast failure a rider cannot work + // around by tilting the phone. Matches [MilerAppBar] and Home's header. + systemOverlayStyle: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), elevation: 0, scrolledUnderElevation: 0, // Everything lives in `title` so the control row and the heading can be @@ -807,7 +856,7 @@ PreferredSizeWidget milerPageBar( title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: MilerType.pageTitleFixed, + style: MilerType.pageTitleFixed.copyWith(color: Colors.white), ), ], ), @@ -885,6 +934,113 @@ class _RevealState extends State with SingleTickerProviderStateMixin { Duration staggerDelay(int index, {int stepMs = 45, int maxMs = 320}) => Duration(milliseconds: (index * stepMs).clamp(0, maxMs)); +/// ───────────────────────────────────────────────────────────────────────── +/// GLASS CARD — the one surface every list card in the app is drawn on. +/// +/// A translucent pane, a soft wide shadow, and **no border**. +/// +/// ── Why no border ── +/// +/// A border, a fill and a shadow are three devices doing one job, and the +/// border is the one that competes with the content: six bordered rectangles +/// down a phone screen read as a form to be filled in, not a route to be +/// ridden. Every large logistics app the rider already uses — Uber, Swiggy, +/// Amazon Shopper — separates list cards with elevation alone, and it survives +/// what a hairline does not: a scratched screen in direct sun, where a 1px +/// #E2E8F0 line is simply not there. +/// +/// ── Why one widget rather than a decoration constant ── +/// +/// Two screens draw these cards (Home's route and the work tab's jobs) and they +/// drifted apart every single time the recipe was a constant each copied. The +/// press feedback belongs here too: a card that opens a sheet ~340ms later +/// needs to acknowledge the tap, and that acknowledgement should not be +/// something each caller remembers to add. +/// ───────────────────────────────────────────────────────────────────────── +class GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? padding; + + /// Opens whatever this card is about. Null makes it a plain surface. + final VoidCallback? onTap; + + /// What a screen reader calls the card. + final String? semanticLabel; + final String? semanticHint; + + /// A wash over the pane, for the one card on a screen that is *live*. + /// Deliberately a whisper — see [ColorConstants.glassCardLive]. + final Color? tint; + + /// Dims the whole card without changing a single colour inside it, for a + /// card that is no longer actionable. + final double opacity; + + final double radius; + + const GlassCard({ + super.key, + required this.child, + this.margin, + this.padding, + this.onTap, + this.semanticLabel, + this.semanticHint, + this.tint, + this.opacity = 1, + this.radius = 22, + }); + + @override + Widget build(BuildContext context) { + final surface = Container( + margin: margin, + padding: padding, + decoration: BoxDecoration( + color: ColorConstants.glassCard, + borderRadius: BorderRadius.circular(radius), + // ── The rim is what a budget LCD can actually render ── + // + // On a calibrated panel the slate shadow separates the pane from the + // ground; on the LCD this app ships to, shadows crush to nothing and + // the cards read as printed on the page — reported twice from the + // device. The one-pixel rim is the same trick the frosted sheets use + // ([milerGlassSheet]'s glassRim): a line of light on the curve costs + // nothing at any brightness and says "edge" where blur cannot. + border: Border.all(color: ColorConstants.borderSubtle), + boxShadow: DesignConstants.shadowGlass, + ), + // The tint rides *inside* the pane rather than replacing it, so a live + // card is the same glass everything else is, warmed — not a different + // material. + child: tint == null + ? child + : DecoratedBox( + decoration: BoxDecoration( + color: tint, + borderRadius: BorderRadius.circular(radius), + ), + child: child, + ), + ); + + final tappable = onTap == null + ? surface + : PressScale( + onTap: onTap, + // No `semanticLabel` unless the caller asks for one: PressScale + // *excludes* its descendants' semantics when it labels itself, and + // these cards carry buttons a reader has to be able to reach. + semanticLabel: semanticLabel, + semanticHint: semanticHint, + child: surface, + ); + + return opacity == 1 ? tappable : Opacity(opacity: opacity, child: tappable); + } +} + /// Subtle press feedback — scales the [child] down slightly while held, then /// springs back. Use it to make cards and custom buttons feel tappable. class PressScale extends StatefulWidget { @@ -1068,11 +1224,21 @@ class SkeletonList extends StatelessWidget { @override Widget build(BuildContext context) { return MilerShimmer( - child: Column( - children: [ - const SizedBox(height: 8), - for (int i = 0; i < count; i++) skeletonCard(), - ], + // ── Clipped, not overflowed ── + // + // A bare `Column` of five cards is ~820pt tall, and the body of a pushed + // page on the shortest phone the app supports is ~768. So the one screen + // whose whole job is to say "this is coming" greeted the rider with a + // black-and-yellow overflow band. A skeleton is a placeholder: it should + // fill what it is given and stop, never demand a height. + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + const SizedBox(height: 8), + for (int i = 0; i < count; i++) skeletonCard(), + ], + ), ), ); } @@ -1243,7 +1409,7 @@ class MapPlaceholder extends StatelessWidget { ), alignment: Alignment.center, child: Icon( - Icons.place_rounded, + LucideIcons.mapPin, size: 30, color: ColorConstants.onSurfaceVariant.withValues(alpha: 0.35), ), @@ -1354,7 +1520,7 @@ class MilerButton extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: fontSize, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.1, color: fg, fontFamily: FontConstants.fontFamily, @@ -1505,10 +1671,7 @@ class MilerCodeBox { /// The digit. static TextStyle digit(Color accent, {bool hasError = false}) => - MilerType.figure( - 20, - color: hasError ? ColorConstants.errorRed : accent, - ); + MilerType.figure(20, color: hasError ? ColorConstants.errorRed : accent); } /// ─────────────────────────────────────────────────────────────────────────── @@ -1554,7 +1717,7 @@ class AppFeedback { }) => _show( context, message, - icon: Icons.error_outline_rounded, + icon: LucideIcons.circleAlert, fill: ColorConstants.errorRed, duration: duration, onRetry: onRetry, @@ -1565,7 +1728,7 @@ class AppFeedback { static void success(BuildContext context, String message) => _show( context, message, - icon: Icons.check_circle_rounded, + icon: LucideIcons.circleCheck, fill: ColorConstants.acceptGreen, duration: const Duration(seconds: 2), ); @@ -1575,7 +1738,7 @@ class AppFeedback { static void info(BuildContext context, String message) => _show( context, message, - icon: Icons.info_outline_rounded, + icon: LucideIcons.info, fill: ColorConstants.slateText, duration: const Duration(seconds: 3), ); @@ -1677,7 +1840,7 @@ class ErrorRetry extends StatelessWidget { @override Widget build(BuildContext context) { return MilerEmptyState( - icon: Icons.wifi_off_rounded, + icon: LucideIcons.wifiOff, title: title, message: message, actionLabel: 'Try again', @@ -1685,3 +1848,182 @@ class ErrorRetry extends StatelessWidget { ); } } + +/// ───────────────────────────────────────────────────────────────────────── +/// WHERE THIS PARCEL CAME FROM, AND WHICH ONE IT IS +/// +/// `[ Kitchen 1 ] Bag DG-1042` +/// +/// Driven by the data, not by which line the rider is on: it renders whatever +/// of the two facts the stop actually carries, and collapses to nothing when it +/// carries neither. A parcel booking has no kitchen and no bag label to send — +/// everything on that route came from the one hub the rider started at — so a +/// parcel card is unchanged without the widget having to ask which tenant it is +/// on. If a parcel payload ever does name a source, showing it is the right +/// answer anyway. +/// +/// On a meal run both facts earn their space. A rider part-way through a +/// two-kitchen day is carrying two loads that look identical in the box, and +/// the failure that costs the client money is not a missed stop — it is the +/// right door with the wrong bag, which no OTP, photo or signature catches, +/// because the rider is confident and the customer has no way to know. A +/// printed label on the card he is already reading is the cheapest thing that +/// does. +/// +/// The source is a pill and the bag is plain text, deliberately: the pill is +/// scannable down a list, which is what grouping needs, while the label is +/// checked once against something physically in his hand. +/// +/// Sized in raw logical pixels like everything else in this file — see the note +/// on [MilerEmptyState]; the two stop cards that host it are already +/// ScreenUtil-scaled around it. +/// ───────────────────────────────────────────────────────────────────────── +class StopSourceLine extends StatelessWidget { + final String source; + final String bagLabel; + + /// Dims both when the stop is not actionable, matching the card around it. + final bool muted; + + /// Space above the line. Zero when a caller has already placed it in a row of + /// its own and is spacing it itself. + final double topGap; + + const StopSourceLine({ + super.key, + required this.source, + required this.bagLabel, + this.muted = false, + this.topGap = 6, + }); + + bool get _isEmpty => source.isEmpty && bagLabel.isEmpty; + + @override + Widget build(BuildContext context) { + if (_isEmpty) return const SizedBox.shrink(); + + final accent = muted + ? ColorConstants.secondaryText + : ColorConstants.serviceAccent; + + return Padding( + padding: EdgeInsets.only(top: topGap), + child: Row( + children: [ + if (source.isNotEmpty) + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.09), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.store, size: 12, color: accent), + const SizedBox(width: 5), + Flexible( + child: Text( + source, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ), + if (source.isNotEmpty && bagLabel.isNotEmpty) + const SizedBox(width: 8), + if (bagLabel.isNotEmpty) + Flexible( + child: Text( + 'Bag $bagLabel', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + // Tabular, because these are read as codes and a column of + // them down a list should line up. + fontFeatures: const [FontFeature.tabularFigures()], + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } +} + +/// ───────────────────────────────────────────────────────────────────────── +/// WHERE THIS ORDER HAS GOT TO +/// +/// `⏱ Assigned` · `✓ Accepted` · `🚲 In progress` · `✔ Completed` +/// +/// One tag, on every card that shows an order, on every screen. The status +/// vocabulary already lives in one place — [StopStatusX.label] — and this is +/// the matching one place for how it looks, so Home, Bookings and Activity +/// cannot end up drawing the same backend state three different ways, which is +/// exactly what happened to the words before they were centralised. +/// +/// Icon **and** colour, never colour alone: these are read outdoors, in +/// sunlight, on a scratched screen, by somebody who is in a hurry. +/// ───────────────────────────────────────────────────────────────────────── +class OrderStatusTag extends StatelessWidget { + final StopStatus status; + + /// Dims it to match a card that is not actionable. + final bool muted; + + /// Sentence-case label. Sizing is raw logical pixels, like the rest of this + /// file — the cards hosting it are ScreenUtil-scaled around it. + const OrderStatusTag({super.key, required this.status, this.muted = false}); + + @override + Widget build(BuildContext context) { + final color = muted ? ColorConstants.secondaryText : status.color; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(status.icon, size: 12, color: color), + const SizedBox(width: 5), + Flexible( + child: Text( + status.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: color, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/helpers/widgets/load_state_view.dart b/lib/views/helpers/widgets/load_state_view.dart new file mode 100644 index 0000000..ea1ec5e --- /dev/null +++ b/lib/views/helpers/widgets/load_state_view.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/data/load_state.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE ANSWER PER STATE, WRITTEN ONCE +/// +/// Every screen rendered its own version of "nothing here": Home had a trip +/// empty state, Bookings had a tick in a circle, Activity had a Lottie, and +/// each of them decided independently what a *failure* looked like — usually by +/// rendering the empty one, which tells a rider to wait for work that is +/// actually a dead request. +/// +/// This is the mapping from [LoadState] to what the rider sees, in one place, +/// so the four screens cannot disagree about what a 429 means. +/// +/// ── The distinction that matters ── +/// +/// [LoadUnavailable] is not an error and not an empty day. The rider's line of +/// work has no endpoint behind it, so neither waiting nor retrying will change +/// anything — and offering him a Retry button would be a lie told once per tap. +/// It gets its own state, with no retry, saying what is actually true. +/// ───────────────────────────────────────────────────────────────────────── +class LoadStateView extends StatelessWidget { + final LoadState state; + + /// Drawn when there is data. Receives the value and whether a refresh is + /// running behind it — a pull-to-refresh must not blank its own list. + final Widget Function(T value, bool refreshing) builder; + + /// What "nothing today" looks like on this screen. Screens keep their own, + /// because an empty route and an empty history are different sentences. + final Widget empty; + + /// Re-runs the load. Null on screens with nothing to retry with. + final Future Function()? onRetry; + + /// Shown instead of a spinner on first load, where a screen has a skeleton + /// that matches its real layout. + final Widget? loading; + + const LoadStateView({ + super.key, + required this.state, + required this.builder, + required this.empty, + this.onRetry, + this.loading, + }); + + @override + Widget build(BuildContext context) => switch (state) { + LoadLoading() => + loading ?? + Padding( + padding: EdgeInsets.symmetric(vertical: 48.h), + child: const Center(child: CircularProgressIndicator()), + ), + LoadData(:final value, :final refreshing) => builder(value, refreshing), + LoadEmpty() => empty, + LoadUnavailable(:final reason) => _Message( + icon: LucideIcons.cloudOff, + tint: ColorConstants.secondaryText, + title: 'Not available yet', + body: reason, + // Deliberately no retry. Nothing the rider does changes this, and a + // button that cannot work is worse than no button. + onRetry: null, + ), + LoadFailure(:final kind, :final message) => _Message( + icon: switch (kind) { + LoadFailureKind.offline => LucideIcons.wifiOff, + LoadFailureKind.unauthorized => LucideIcons.lock, + LoadFailureKind.rateLimited => LucideIcons.hourglass, + LoadFailureKind.server => LucideIcons.circleAlert, + }, + tint: kind == LoadFailureKind.offline + ? ColorConstants.secondaryText + : ColorConstants.warning, + title: switch (kind) { + LoadFailureKind.offline => 'No connection', + LoadFailureKind.unauthorized => 'Signed out', + LoadFailureKind.rateLimited => 'Too many requests', + LoadFailureKind.server => "Couldn't load your work", + }, + body: switch (kind) { + LoadFailureKind.offline => + 'Your work will appear as soon as you are back online.', + LoadFailureKind.unauthorized => + 'Your session ended. Sign in again to carry on.', + LoadFailureKind.rateLimited => 'Give it a moment and try again.', + // The server's own sentence where it gave one — it is more useful than + // anything this app can invent about a failure it did not cause. + LoadFailureKind.server => + message.isEmpty ? 'The hub could not be reached.' : message, + }, + onRetry: kind.isRetryable ? onRetry : null, + ), + }; +} + +/// A glyph, a sentence and — only where it can help — one action. +class _Message extends StatelessWidget { + final IconData icon; + final Color tint; + final String title; + final String body; + final Future Function()? onRetry; + + const _Message({ + required this.icon, + required this.tint, + required this.title, + required this.body, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB(32.w, 44.h, 32.w, 32.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 60.w, + height: 60.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: tint.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 27.sp, color: tint), + ), + SizedBox(height: 18.h), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + Text( + body, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + height: 1.45, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + if (onRetry != null) ...[ + SizedBox(height: 20.h), + SizedBox( + height: ButtonSizes.secondary, + child: OutlinedButton.icon( + onPressed: onRetry, + icon: Icon(LucideIcons.rotateCw, size: 18.sp), + label: Text( + 'Try again', + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + style: OutlinedButton.styleFrom( + foregroundColor: ColorConstants.primary, + side: BorderSide(color: ColorConstants.borderStrong), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/views/helpers/widgets/miler_app_bar.dart b/lib/views/helpers/widgets/miler_app_bar.dart index 5187655..f3c16e4 100644 --- a/lib/views/helpers/widgets/miler_app_bar.dart +++ b/lib/views/helpers/widgets/miler_app_bar.dart @@ -2,10 +2,13 @@ import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; @@ -180,7 +183,15 @@ class MilerAvatar extends StatelessWidget { /// the controller and callers that do not can leave it off. final String? name; - const MilerAvatar({super.key, this.name}); + /// Drawn against the brand ground rather than against a surface. + /// + /// The fallback circle is brand-filled with a white letter, which is exactly + /// invisible on Home's branded header. On brand the fill and the letter swap — + /// a white disc carrying a maroon initial — and a photo picks up a white ring + /// so it reads as a portrait on the ground rather than a hole cut in it. + final bool onBrand; + + const MilerAvatar({super.key, this.name, this.onBrand = false}); /// Matches [MilerBrandMark._boxSize] — this widget takes over that slot, and /// the bar's height is built around it. @@ -231,10 +242,18 @@ class MilerAvatar extends StatelessWidget { shape: BoxShape.circle, color: photo != null ? ColorConstants.neutralLight + : onBrand + ? ColorConstants.onAccent : ColorConstants.primary, image: photo != null ? DecorationImage(image: FileImage(photo), fit: BoxFit.cover) : null, + border: onBrand && photo != null + ? Border.all( + color: Colors.white.withValues(alpha: 0.35), + width: 1.5, + ) + : null, ), alignment: Alignment.center, child: photo != null @@ -245,9 +264,11 @@ class MilerAvatar extends StatelessWidget { // Fixed .sp off a fixed box: the circle does not grow with // the system font scale, so neither can the letter inside it. fontSize: 19.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, height: 1, - color: Colors.white, + color: onBrand + ? ColorConstants.primary + : ColorConstants.onAccent, fontFamily: FontConstants.fontFamily, ), ), @@ -321,8 +342,31 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return AppBar( - backgroundColor: Colors.transparent, - flexibleSpace: milerGlassSurface(), + // ── One bar, one ground, across every tab ── + // + // The four tabs wore a 7% frosted brand wash flattened against white, + // while Home wore solid brand. That is two chromes in one app: switching + // from Home to Bookings changed the colour of the top 76pt of the phone + // and the colour of the status-bar glyphs with it, which reads as + // arriving in a different application rather than a different page. + // + // Solid [ColorConstants.primary] everywhere, and everything on it + // inverts — title, back arrow, trailing controls, status-bar glyphs. + // White on #960019 clears AA at every size used here. + backgroundColor: ColorConstants.primary, + // The status bar belongs to the bar under it. Dark glyphs on maroon is + // the one contrast failure a rider cannot work around by tilting the + // phone. Matches the value Home's own header sets. + systemOverlayStyle: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + // Everything drawn inside inherits white, so a trailing control does not + // have to be told twice. + foregroundColor: Colors.white, + iconTheme: const IconThemeData(color: Colors.white), + actionsIconTheme: const IconThemeData(color: Colors.white), elevation: 0, scrolledUnderElevation: 0, centerTitle: false, @@ -344,15 +388,17 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { label: 'Back', child: InkWell( onTap: () => Navigator.of(context).maybePop(), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), child: SizedBox( width: 48.w, height: 48.w, // One back glyph app-wide — this was one of three. child: Icon( - Icons.arrow_back_rounded, + LucideIcons.arrowLeft, size: 22.sp, - color: ColorConstants.slateText, + color: Colors.white, ), ), ), @@ -372,7 +418,9 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: MilerType.pageTitleFixed, + style: MilerType.pageTitleFixed.copyWith( + color: Colors.white, + ), ), ], ), @@ -384,3 +432,76 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { ); } } + +/// The page's content, as a sheet tucked under the brand header. +/// +/// ── Why the corners ── +/// +/// [MilerAppBar] paints a solid maroon band and the page began flush under it, +/// so the two met on a hard horizontal line the full width of the phone. That +/// line is what made every tab read as *a coloured strip above a form*: the bar +/// looked bolted on rather than like the top of the screen the content lives +/// in. +/// +/// Rounding the content's top corners and letting the brand show through behind +/// them turns the two into one object — a header the page is *inside*. It is +/// the shape every consumer app the rider uses has settled on, and it costs one +/// clip and a colour behind it. +/// +/// ── How to use it ── +/// +/// The scaffold's own background becomes [ColorConstants.primary] so the brand +/// is what shows in the two corners; this widget paints the real page colour +/// and clips to it. Nothing inside needs to know. +/// +/// ```dart +/// Scaffold( +/// backgroundColor: ColorConstants.primary, +/// appBar: const MilerAppBar(title: 'Activity'), +/// body: MilerSheet(child: …), +/// ) +/// ``` +/// +/// **Clip, not just a rounded box.** The child is a scrolling list on three of +/// the four tabs, and a `BoxDecoration` alone would let rows paint over the +/// corners on the first pixel of a scroll — which reads as the radius flicking +/// off and on. +class MilerSheet extends StatelessWidget { + final Widget child; + + /// The page colour under the clip. Defaults to the app's paper. + final Color? color; + + const MilerSheet({super.key, required this.child, this.color}); + + /// Big enough to read as a shape rather than as a softened edge, small enough + /// that a list's first row does not look pushed in. Matches the sheet radius + /// the bottom sheets already use. + static const double radius = 22; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(radius.r)), + child: ColoredBox( + // ── Layer 1: this IS the working surface ── + // + // Tried as the canvas and reverted, with the reason recorded because + // it is the trap in this whole exercise: Home, Deliveries and Activity + // deliberately have **no cards** — the route is a spine and the rows + // sit directly on the page. Painting that page with the canvas tone + // therefore produced a uniformly grey screen with nothing on it to + // separate *from*, which is the same flatness in a different colour. + // + // A darker ground only helps where something white actually stands on + // it: the Deliveries NOW card, the record page's folded groups, every + // modal sheet. Those all sit on [MilerSurface.canvas] and now separate + // at 1.290:1 instead of 1.124. + // + // So this stays white and *is* the working layer. See [MilerSurface]. + color: color ?? MilerSurface.working, + child: child, + ), + ); + } +} diff --git a/lib/views/helpers/widgets/miler_map.dart b/lib/views/helpers/widgets/miler_map.dart index 0b04e36..16ef9cd 100644 --- a/lib/views/helpers/widgets/miler_map.dart +++ b/lib/views/helpers/widgets/miler_map.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' show LatLng; @@ -148,7 +149,7 @@ class _MilerAttribution extends StatelessWidget { '© OpenStreetMap', style: TextStyle( fontSize: 9, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -338,13 +339,13 @@ class MilerNumberPin extends StatelessWidget { ], ), child: done - ? const Icon(Icons.check_rounded, size: 16, color: Colors.white) + ? const Icon(LucideIcons.check, size: 16, color: Colors.white) : Text( '$number', style: const TextStyle( fontSize: 14, height: 1, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -445,3 +446,148 @@ double milerDistanceMetres(LatLng a, LatLng b) { } double _rad(double deg) => deg * math.pi / 180.0; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE LEG, DRAWN HONESTLY +/// +/// The small orientation maps on the stop detail and pickup preview sheets +/// each hand-rolled the same picture and both told the same two lies: +/// +/// • **A straight line pretending to be the ride.** The polyline was just +/// `[rider, stop]`, drawn solid — geometry no road follows, in the same +/// stroke the real map screen uses for an actual route. +/// • **A journey with one end.** The camera parked on the destination at a +/// fixed zoom, so a rider 4 km out watched the line exit the frame into +/// nothing — a pin, a stroke, and no second point anywhere on screen. +/// +/// This widget is that picture done once, properly. It asks OSRM for the road +/// (same [MilerRouter] the map screen trusts), fits the camera to BOTH ends +/// with room to breathe, and always draws both endpoints — the destination +/// pin and the rider's dot. While the route is in flight, or when the router +/// is unreachable, the leg shows as a DASHED direct line: dashes are the map +/// vocabulary for "as the crow flies", so the fallback stops impersonating a +/// road. Non-interactive by design — orientation, not navigation. +/// ───────────────────────────────────────────────────────────────────────── +class MilerLegMap extends StatefulWidget { + /// The destination — the one end a leg always has. + final LatLng to; + + /// Where the rider is. Null draws the destination alone, centred. + final LatLng? from; + + /// The destination pin's accent and glyph — pass the stop's own. + final Color accent; + final IconData icon; + + const MilerLegMap({ + super.key, + required this.to, + required this.accent, + required this.icon, + this.from, + }); + + @override + State createState() => _MilerLegMapState(); +} + +class _MilerLegMapState extends State { + final MapController _controller = MapController(); + List _road = const []; + bool _ready = false; + + @override + void initState() { + super.initState(); + _fetchRoad(); + } + + @override + void didUpdateWidget(covariant MilerLegMap old) { + super.didUpdateWidget(old); + if (old.from != widget.from || old.to != widget.to) { + _road = const []; + _fetchRoad(); + _fit(); + } + } + + Future _fetchRoad() async { + final from = widget.from; + if (from == null) return; + final road = await MilerRouter.route(from, widget.to); + if (!mounted || road.isEmpty) return; + setState(() => _road = road); + // The road can bulge outside the straight-line bounds; refit around it. + _fit(); + } + + void _fit() { + if (!_ready) return; + final from = widget.from; + if (from == null) return; + final points = _road.isNotEmpty ? _road : [from, widget.to]; + _controller.fitCamera( + CameraFit.coordinates( + coordinates: points, + padding: const EdgeInsets.fromLTRB(36, 44, 36, 30), + ), + ); + } + + @override + Widget build(BuildContext context) { + final from = widget.from; + + return IgnorePointer( + child: MilerMap( + controller: _controller, + initialCenter: from == null + ? widget.to + : LatLng( + (widget.to.latitude + from.latitude) / 2, + (widget.to.longitude + from.longitude) / 2, + ), + initialZoom: 13, + onReady: () { + _ready = true; + // After layout, when the viewport has a size to fit within. + WidgetsBinding.instance.addPostFrameCallback((_) => _fit()); + }, + polylines: [ + if (from != null) + Polyline( + points: _road.isNotEmpty ? _road : [from, widget.to], + color: widget.accent, + strokeWidth: 4, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + // Dashed while it is a bearing, solid once it is a road. + pattern: _road.isNotEmpty + ? const StrokePattern.solid() + : StrokePattern.dashed(segments: const [14, 10]), + ), + ], + markers: [ + milerMarker( + point: widget.to, + width: MilerPin.size + 8, + height: MilerPin.size + MilerPin.tail, + child: MilerPin( + color: widget.accent, + icon: widget.icon, + emphasised: true, + ), + ), + if (from != null) + milerMarker( + point: from, + width: MilerRiderDot.size, + height: MilerRiderDot.size, + child: const MilerRiderDot(), + ), + ], + ), + ); + } +} diff --git a/lib/views/helpers/widgets/miler_sheet_kit.dart b/lib/views/helpers/widgets/miler_sheet_kit.dart new file mode 100644 index 0000000..280348b --- /dev/null +++ b/lib/views/helpers/widgets/miler_sheet_kit.dart @@ -0,0 +1,378 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart' + show milerGlassSheet; +import 'package:miler/views/helpers/widgets/page_transitions.dart' + show kMilerSheetStyle, kMilerLargeSheetStyle; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE SHEET KIT — one way to present a modal sheet, one structure inside it +/// +/// ── What this replaced ── +/// +/// An audit of every `showModalBottomSheet` in `lib/` (outside reference-only +/// `lib/xpress`) found six presentation styles for one kind of surface: +/// +/// • frosted glass, radius 28 — duty, action, skip, pickup, map +/// • opaque `pureSurface`, radius 24 — stop detail, pickup preview +/// • default white, radius 16, and NO +/// `isScrollControlled` — the reject / cancel sheets, whose +/// reason field the keyboard covered +/// • an ad-hoc Container with its own +/// shadow and a 50pt top margin — the trip-map sheet +/// • `Get.bottomSheet`, flat 16 — every auth error +/// • a floating card with margins — the payment confirm +/// +/// Each also drew its own drag handle — 40×4 in `borderSubtle` in four files, +/// which on frosted glass is close to invisible, and 56×4 in a fifth — and +/// applied `SafeArea` its own way. A rider crossing one shift met most of +/// these, which is precisely what makes an app feel assembled rather than +/// designed: the same gesture arriving on different-looking surfaces. +/// +/// The material itself was never the problem — [milerGlassSheet] is the app's +/// committed sheet fabric and stays exactly as it is. What was missing is the +/// layer above it: one presenter that owns the route configuration, and one +/// scaffold that owns handle + padding + keyboard + gesture inset, so a sheet +/// author writes content and nothing else. +/// +/// ── The rules the kit enforces ── +/// +/// • **Glass, always.** `backgroundColor: transparent` on the route; the +/// scaffold paints the fabric. No caller re-declares a shape. +/// • **One handle.** 40×4, `borderStrong` — `borderSubtle` at 7% glass over a +/// dimmed page measures under 1.15:1 against its own ground. +/// • **One inset rule.** Bottom padding is `max(keyboard, gesture bar)` plus +/// the content gap — the keyboard replaces the gesture bar, so summing them +/// (the double-SafeArea bug this app has already had once) pushes the CTA a +/// bar's height above the keyboard. +/// • **Scroll-controlled, always.** A sheet that cannot grow past half height +/// is a sheet whose text field the keyboard covers. +/// ───────────────────────────────────────────────────────────────────────── + +/// Presents [builder]'s widget as a Miler modal sheet. +/// +/// The one place `showModalBottomSheet` is configured. [large] picks the +/// slower entrance tuned for sheets that cover most of the screen — see +/// [kMilerLargeSheetStyle]. +Future showMilerSheet( + BuildContext context, { + required WidgetBuilder builder, + bool large = false, + bool isDismissible = true, + bool enableDrag = true, +}) { + return showModalBottomSheet( + context: context, + sheetAnimationStyle: large ? kMilerLargeSheetStyle : kMilerSheetStyle, + isScrollControlled: true, + // The scaffold paints the glass; an opaque route background would show as + // a square white corner behind the rounded one. + backgroundColor: Colors.transparent, + // ── The scrim is the separation; the shadow is not ── + // + // Layer 3 sits over everything, and what tells the rider it is *over* is + // the page going dark behind it. This was Flutter's default `black54`, + // which is a Material number rather than a Miler one — and on the frosted + // fabric this app uses, a lighter dim leaves the sheet's own translucency + // reading as part of the page beneath it. + // + // [MilerSurface.scrim] is the app's, stated once, so every sheet dims the + // same amount. A sheet that needs a heavier shadow to be legible is a + // sheet whose scrim is too weak — this is the knob, not the shadow. + barrierColor: MilerSurface.scrim, + isDismissible: isDismissible, + enableDrag: enableDrag, + builder: builder, + ); +} + +/// The structure every sheet shares: glass fabric, drag handle, content +/// padding, and the bottom inset done once. +/// +/// The body is not scrolled for you — a short confirm sheet has nothing to +/// scroll and a tall one owns its own `ListView` — but the insets are: the +/// scaffold reads the keyboard and the gesture bar so its child never has to +/// mention either. +class MilerSheetScaffold extends StatelessWidget { + final Widget child; + + /// Hide only for a sheet that must not be casually flicked away. + final bool handle; + + /// Horizontal content inset. 20 matches the page gutter the sheet rises + /// over; the two sheets that need edge-to-edge content (a map, a full-bleed + /// list) pass their own. + final EdgeInsetsGeometry? padding; + + const MilerSheetScaffold({ + super.key, + required this.child, + this.handle = true, + this.padding, + }); + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + // The keyboard replaces the gesture bar; whichever is up pays the inset. + // Adding them — the double-SafeArea mistake — floats the CTA a gesture + // bar's height above the keyboard. + final double bottomInset = math.max( + media.viewInsets.bottom, + media.viewPadding.bottom, + ); + + return milerGlassSheet( + child: AnimatedPadding( + // The keyboard animates in; a sheet that jumps its full keyboard + // height in one frame reads as a glitch, not a response. + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + padding: EdgeInsets.only(bottom: bottomInset), + child: Padding( + padding: padding ?? EdgeInsets.fromLTRB(20.w, 0, 20.w, 18.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (handle) const MilerSheetHandle() else SizedBox(height: 14.h), + Flexible(child: child), + ], + ), + ), + ), + ); + } +} + +/// The drag handle: the one affordance saying "this surface moves". +/// +/// `borderStrong`, not `borderSubtle` — four sheets drew the subtle grey on +/// frosted glass, where it is effectively invisible, so the sheets most worth +/// flicking away looked least like they could be. +class MilerSheetHandle extends StatelessWidget { + const MilerSheetHandle({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 40.w, + height: 4.h, + margin: EdgeInsets.only(top: 10.h, bottom: 14.h), + decoration: BoxDecoration( + color: ColorConstants.borderStrong, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + ), + ); + } +} + +/// A sheet's opening statement: what this is, and the one line under it. +/// +/// The leading badge is optional and tinted, never filled — a filled disc at +/// this size outweighs the title it introduces. Pass the accent that the +/// sheet's subject already wears elsewhere (the duty green, the brand red, a +/// stop-kind accent) so the sheet is visibly about the thing that opened it. +class MilerSheetHeader extends StatelessWidget { + final String title; + final String? subtitle; + final IconData? icon; + final Color? tint; + + const MilerSheetHeader({ + super.key, + required this.title, + this.subtitle, + this.icon, + this.tint, + }); + + @override + Widget build(BuildContext context) { + final accent = tint ?? ColorConstants.primary; + + final text = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.45, + height: 1.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + if (subtitle != null && subtitle!.trim().isNotEmpty) ...[ + SizedBox(height: 3.h), + Text( + subtitle!, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, + height: 1.35, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ); + + if (icon == null) return text; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42.w, + height: 42.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon(icon, size: 21.sp, color: accent), + ), + SizedBox(width: 12.w), + Expanded(child: text), + ], + ); + } +} + +/// ── One answer in a reason list ── +/// +/// Update Status, Skip and Reject all ask the same shape of question — "why?", +/// a handful of answers, pick one — and drew it three different ways: stacks +/// of outlined white cards with each icon inside a second box, and (on +/// Reject) solid-primary slabs that outshouted the destructive CTA under +/// them. +/// +/// A hairline separates, the tick answers, and selection is carried by a soft +/// fill rather than a thicker outline or a full-strength slab. A fill is what +/// survives sunlight; a 1.5px border is what does not — see +/// [ColorConstants.cardSurface]. Promoted here from the pickups library's +/// private `_ReasonRow` the day the Reject sheet became its third caller. +class MilerSheetChoiceRow extends StatelessWidget { + final String label; + + /// Optional leading glyph. A list whose answers have no natural icons reads + /// fine without any — the tick still says which one is chosen. + final IconData? icon; + + final Color accent; + final bool selected; + + /// The hairline above this row. Suppressed either side of the selected row + /// so it does not cut across the tint and turn the one chosen answer back + /// into a box. + final bool showRule; + + final VoidCallback? onTap; + + const MilerSheetChoiceRow({ + super.key, + required this.label, + required this.selected, + this.icon, + this.accent = ColorConstants.primary, + this.showRule = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (showRule) + Divider( + height: 1, + thickness: 1, + indent: icon == null ? 0 : 33.w, + color: ColorConstants.borderSubtle, + ), + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: AnimatedContainer( + duration: DesignConstants.motionState, + padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 11.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + color: selected + ? accent.withValues(alpha: 0.10) + : Colors.transparent, + ), + child: Row( + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 19.sp, + color: selected ? accent : ColorConstants.secondaryText, + ), + SizedBox(width: 13.w), + ], + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 14.5.sp, + color: selected ? accent : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ), + // The tick appears rather than an empty ring sitting on + // every row: hollow circles read as more things to deal + // with, when only one of them is ever the answer. + AnimatedScale( + duration: DesignConstants.motionState, + curve: Curves.easeOutBack, + scale: selected ? 1 : 0.6, + child: AnimatedOpacity( + duration: DesignConstants.motionState, + opacity: selected ? 1 : 0, + child: Container( + width: 22.w, + height: 22.w, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.check, + color: Colors.white, + size: 14.sp, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/helpers/widgets/page_transitions.dart b/lib/views/helpers/widgets/page_transitions.dart index 0f43f13..5b57945 100644 --- a/lib/views/helpers/widgets/page_transitions.dart +++ b/lib/views/helpers/widgets/page_transitions.dart @@ -257,7 +257,22 @@ Future showAppDialog({ barrierColor: barrierColor, useRootNavigator: useRootNavigator, transitionDuration: const Duration(milliseconds: 260), - pageBuilder: (context, _, _) => Builder(builder: builder), + // ── Every dialog gets a Material, whether its caller remembered one ── + // + // `showGeneralDialog` hands the builder a bare route with no Material in + // scope, unlike `showDialog`, which wraps its child in one. A caller that + // returns a `Container` with a `Text` in it — which several do — therefore + // painted Flutter's **missing-Material debug marker**: the yellow double + // underline under every word. It looked like a styling bug in the dialog + // and was actually the framework saying "there is no Material here". + // + // `MaterialType.transparency` adds the ancestor and paints nothing, so a + // dialog that draws its own surface is unchanged and one that forgot stops + // being underlined. + pageBuilder: (context, _, _) => Material( + type: MaterialType.transparency, + child: Builder(builder: builder), + ), transitionBuilder: (context, animation, _, child) { // `drive` rather than a `CurvedAnimation`: this builder runs every frame // of the transition, and a CurvedAnimation built here would be a new diff --git a/lib/views/helpers/widgets/settings_ui.dart b/lib/views/helpers/widgets/settings_ui.dart index 39f01b3..ade5116 100644 --- a/lib/views/helpers/widgets/settings_ui.dart +++ b/lib/views/helpers/widgets/settings_ui.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; -import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; @@ -38,17 +39,18 @@ class SettingsCard extends StatelessWidget { const SettingsCard({super.key, required this.child, this.padding}); + /// ── No outline ── + /// + /// The card carried a hairline border *and* a shadow, which is two + /// separations doing one job: a lift already says "this is in front of the + /// page", and the line drawn round it only added weight. Every surface in the + /// app that a rider scrolls past — the stop cards on Home, the job cards on + /// Deliveries — is a borderless fill under [DesignConstants.shadowGlass], and + /// this is now the same object in the section's own white. static BoxDecoration decoration() => BoxDecoration( color: ColorConstants.surfaceContainerLowest, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all(color: ColorConstants.dividerColor), - boxShadow: [ - BoxShadow( - color: ColorConstants.onSurface.withValues(alpha: 0.04), - blurRadius: 16, - offset: const Offset(0, 6), - ), - ], + boxShadow: DesignConstants.shadowGlass, ); @override @@ -169,7 +171,15 @@ class SettingsRow extends StatelessWidget { @override Widget build(BuildContext context) { - final Color fg = danger ? ColorConstants.primary : ColorConstants.onSurface; + // ── Destructive is red, not brand red ── + // + // The rule the rest of the app already follows: green advances the job at a + // door, brand maroon is navigation and identity, and red destroys. Logout + // was drawn in the brand colour, which made the one row that ends the + // session the same colour as the logo above it. + final Color fg = danger + ? ColorConstants.errorRed + : ColorConstants.onSurface; return PressScale( onTap: onTap, @@ -178,52 +188,135 @@ class SettingsRow extends StatelessWidget { semanticLabel: subtitle == null || subtitle!.trim().isEmpty ? title : '$title. $subtitle', - child: Padding( - // No horizontal inset: the group is no longer inside a card, so the row - // starts on the page's own gutter and the section heading above it - // lines up with the icon rather than floating inboard of it. - padding: EdgeInsets.symmetric(vertical: 12.h), + child: ConstrainedBox( + // A row is the tap target, so the row is never shorter than one. + constraints: BoxConstraints(minHeight: ButtonSizes.secondary.h), + child: Padding( + // No horizontal inset: the group is no longer inside a card, so the + // row starts on the page's own gutter and the section heading above + // it lines up with the icon rather than floating inboard of it. + padding: EdgeInsets.symmetric(vertical: 11.h), + child: Row( + children: [ + // ── The icon does not outrank the title ── + // + // Every glyph on the page was drawn in `onSurface`, the same + // near-black as the words beside it, so a column of twelve icons + // read as loudly as the twelve things they were labelling. They + // are a column of markers, not the content; the title leads and + // the icon locates it. + Icon( + icon, + color: + iconColor ?? (danger ? fg : ColorConstants.secondaryText), + size: 21.sp, + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: MilerType.body.on(fg)), + if (subtitle != null && subtitle!.trim().isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.label, + ), + ], + ], + ), + ), + if (trailing != null) ...[ + SizedBox(width: 10.w), + trailing!, + ] else if (!danger) ...[ + SizedBox(width: 10.w), + // An affordance, not a mark. At `onSurfaceVariant` it was as + // dark as the subtitle it sat beside; it only has to say the + // row goes somewhere. + Icon( + LucideIcons.chevronRight, + color: ColorConstants.borderStrong, + size: 20.sp, + ), + ], + ], + ), + ), + ), + ); + } +} + +/// ───────────────────────────────────────────────────────────────────────── +/// THE ONE CONTROL IN THIS SECTION THAT IS EVER URGENT +/// +/// `⚠ Emergency help 📞` +/// Calls 112 if you are in immediate danger +/// +/// ── A wash, not an outline ── +/// +/// Account and Help & Support each drew this as an `InfoBanner`: an 8% brand +/// fill inside a 16% brand *border*, with the whole sentence set in red. Three +/// ways of saying one thing — and, once the section's cards lost their edges, +/// the only outlined object left on either page. The loudest element on a +/// rider's account was a notice rather than anything he owns. +/// +/// The wash stays, because this row genuinely is not a preference. The border +/// goes, the words go back to the page's near-black so they can be read at a +/// glance rather than decoded, and the red is spent on the two marks that +/// carry the meaning: the warning glyph and the handset. +/// +/// One widget rather than two copies, because the two pages had already +/// drifted apart on the copy — and the sentence that tells a rider what will +/// happen when he presses it is not a thing to have two versions of. +class EmergencyRow extends StatelessWidget { + /// The number that will be dialled. Named in the subtitle, so the rider can + /// see it before he presses and dial it himself if the handset refuses. + final String number; + + final VoidCallback onTap; + + const EmergencyRow({super.key, required this.number, required this.onTap}); + + @override + Widget build(BuildContext context) { + return PressScale( + onTap: onTap, + semanticLabel: 'Emergency. Calls $number', + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), + decoration: BoxDecoration( + color: ColorConstants.primary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), child: Row( children: [ - Icon(icon, color: iconColor ?? fg, size: 22.sp), + Icon( + LucideIcons.triangleAlert, + color: ColorConstants.primary, + size: 22.sp, + ), SizedBox(width: 14.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Emergency help', style: MilerType.body.bold), + SizedBox(height: 2.h), Text( - title, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w600, - color: fg, - fontFamily: FontConstants.fontFamily, - ), + 'Calls $number if you are in immediate danger', + maxLines: 2, + style: MilerType.micro, ), - if (subtitle != null && subtitle!.trim().isNotEmpty) ...[ - SizedBox(height: 2.h), - Text( - subtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.sp, - color: ColorConstants.onSurfaceVariant, - fontFamily: FontConstants.fontFamily, - ), - ), - ], ], ), ), - if (trailing != null) - trailing! - else if (!danger) - Icon( - Icons.chevron_right_rounded, - color: ColorConstants.onSurfaceVariant, - size: 20.sp, - ), + SizedBox(width: 10.w), + Icon(LucideIcons.phone, color: ColorConstants.primary, size: 20.sp), ], ), ), @@ -239,17 +332,21 @@ class SettingsLabel extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: 4.h), - child: Text( - text.toUpperCase(), - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w800, - letterSpacing: 1.1, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), + // ── Aligned to the titles, not to the icons ── + // + // The label used to start on the page gutter, level with the row icons + // below it. Text-to-glyph alignment is optical, never exact: an `Icon` + // does not paint flush to the left of its box, so an 11sp heading and a + // 21sp glyph starting at the same x read as eight points out of true — + // and a column of three headings all eight points out is what makes a + // settings page look assembled rather than laid out. + // + // Text-to-text is exact. The heading sits over the words it heads, and + // the icons become a clean margin rail to its left — the arrangement iOS + // Settings has used since it had groups. The inset is the row's own icon + // box plus its gap, read from the same numbers [SettingsRow] uses. + padding: EdgeInsetsDirectional.only(start: 21.sp + 14.w, bottom: 6.h), + child: Text(text.toUpperCase(), style: MilerType.eyebrow), ); } } @@ -263,16 +360,8 @@ class SettingsHint extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.fromLTRB(4.w, 8.h, 4.w, 0), - child: Text( - text, - style: TextStyle( - fontSize: 12.sp, - height: 1.45, - color: ColorConstants.onSurfaceVariant, - fontFamily: FontConstants.fontFamily, - ), - ), + padding: EdgeInsets.fromLTRB(2.w, 10.h, 2.w, 0), + child: Text(text, style: MilerType.micro.copyWith(height: 1.5)), ); } } diff --git a/lib/views/introscreens/introscreen.dart b/lib/views/introscreens/introscreen.dart index 41ec84a..8ba0c21 100644 --- a/lib/views/introscreens/introscreen.dart +++ b/lib/views/introscreens/introscreen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -170,7 +171,9 @@ class _IntroscreenState extends State { color: active ? ColorConstants.primary : ColorConstants.borderStrong, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), ), ); }), @@ -233,7 +236,7 @@ class _SlideView extends StatelessWidget { textAlign: TextAlign.center, style: TextStyle( fontSize: 25.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.6, height: 1.2, color: ColorConstants.slateText, @@ -306,7 +309,7 @@ const Color _hubTeal = Color(0xFF0E7C7B); TextStyle _artTitle(double size) => TextStyle( fontSize: size.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -314,7 +317,7 @@ TextStyle _artTitle(double size) => TextStyle( TextStyle _artMeta(double size) => TextStyle( fontSize: size.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ); @@ -343,13 +346,15 @@ class _SlotArt extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h), decoration: BoxDecoration( color: ColorConstants.neutralLight, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), border: Border.all(color: ColorConstants.borderStrong), ), child: Row( children: [ Icon( - Icons.inventory_2_rounded, + LucideIcons.package, size: 11.sp, color: ColorConstants.slateText, ), @@ -384,13 +389,13 @@ class _SlotArt extends StatelessWidget { SizedBox(height: 14.h), Row( children: [ - Icon(Icons.warehouse_rounded, size: 13.sp, color: _hubTeal), + Icon(LucideIcons.warehouse, size: 13.sp, color: _hubTeal), SizedBox(width: 6.w), Text( 'HUB', style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.6, color: _hubTeal, fontFamily: FontConstants.fontFamily, @@ -417,7 +422,7 @@ class _SlotArt extends StatelessWidget { height: 2, color: ColorConstants.borderStrong, ), - Icon(Icons.warehouse_rounded, size: 13.sp, color: _hubTeal), + Icon(LucideIcons.warehouse, size: 13.sp, color: _hubTeal), ], ), ], @@ -432,7 +437,7 @@ class _SlotArt extends StatelessWidget { value, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.3, color: accent, fontFamily: FontConstants.fontFamily, @@ -460,7 +465,7 @@ class _StopsArt extends StatelessWidget { 'Sri Balaji Stores', 'Done', ColorConstants.acceptGreen, - Icons.check_rounded, + LucideIcons.check, ), SizedBox(height: 9.h), _row( @@ -468,7 +473,7 @@ class _StopsArt extends StatelessWidget { 'Anand Traders', 'Skipped', ColorConstants.warning, - Icons.redo_rounded, + LucideIcons.redo2, ), SizedBox(height: 9.h), _row(3, 'Kumar Textiles', 'Next', ColorConstants.primary, null), @@ -504,7 +509,7 @@ class _StopsArt extends StatelessWidget { '$n', style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: pending ? ColorConstants.secondaryText : Colors.white, @@ -520,7 +525,7 @@ class _StopsArt extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: pending ? ColorConstants.secondaryText @@ -534,7 +539,7 @@ class _StopsArt extends StatelessWidget { status, style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: accent, fontFamily: FontConstants.fontFamily, ), @@ -556,30 +561,18 @@ class _HubArt extends StatelessWidget { 'COLLECT AT THIS STOP', style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.9, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 11.h), - _line( - Icons.inventory_2_rounded, - '3 parcels', - ColorConstants.pickupAccent, - ), + _line(LucideIcons.package, '3 parcels', ColorConstants.pickupAccent), SizedBox(height: 9.h), - _line( - Icons.payments_rounded, - '₹340 cash', - ColorConstants.acceptGreen, - ), + _line(LucideIcons.banknote, '₹340 cash', ColorConstants.acceptGreen), SizedBox(height: 9.h), - _line( - Icons.photo_camera_rounded, - 'Photo proof', - ColorConstants.slateText, - ), + _line(LucideIcons.camera, 'Photo proof', ColorConstants.slateText), SizedBox(height: 14.h), Container(height: 1, color: ColorConstants.borderSubtle), SizedBox(height: 13.h), @@ -595,7 +588,7 @@ class _HubArt extends StatelessWidget { border: Border.all(color: _hubTeal, width: 1.5), ), child: Icon( - Icons.warehouse_rounded, + LucideIcons.warehouse, size: 12.sp, color: _hubTeal, ), @@ -609,7 +602,7 @@ class _HubArt extends StatelessWidget { 'RETURN · HUB', style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.7, color: _hubTeal, fontFamily: FontConstants.fontFamily, @@ -635,7 +628,7 @@ class _HubArt extends StatelessWidget { text, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/offline/offline_banner.dart b/lib/views/offline/offline_banner.dart index 4cee272..5d2d4ad 100644 --- a/lib/views/offline/offline_banner.dart +++ b/lib/views/offline/offline_banner.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; @@ -56,7 +57,7 @@ class OfflineBanner extends StatelessWidget { child: Row( children: [ Icon( - Icons.cloud_off_rounded, + LucideIcons.cloudOff, size: 15.sp, color: ColorConstants.onAccent, ), @@ -68,9 +69,7 @@ class OfflineBanner extends StatelessWidget { 'Offline · Changes will sync when you’re back online', maxLines: 2, overflow: TextOverflow.ellipsis, - style: MilerType.micro - .on(ColorConstants.onAccent) - .semibold, + style: MilerType.micro.on(ColorConstants.onAccent).semibold, ), ), ], @@ -101,7 +100,9 @@ class OfflineScope extends StatelessWidget { duration: DesignConstants.motionState, curve: Curves.easeOutCubic, alignment: Alignment.bottomCenter, - child: offline ? const OfflineBanner() : const SizedBox(width: double.infinity), + child: offline + ? const OfflineBanner() + : const SizedBox(width: double.infinity), ), Expanded(child: child), ], diff --git a/lib/views/offline/offline_page.dart b/lib/views/offline/offline_page.dart index 5c21b5d..a6a2910 100644 --- a/lib/views/offline/offline_page.dart +++ b/lib/views/offline/offline_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:get/get.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; @@ -120,10 +121,14 @@ class _OfflinePageState extends State padding: const EdgeInsets.all(28), decoration: BoxDecoration( color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), boxShadow: [ BoxShadow( - color: ColorConstants.slateText.withValues(alpha: .05), + color: ColorConstants.slateText.withValues( + alpha: .05, + ), blurRadius: 25, offset: const Offset(0, 12), ), @@ -142,11 +147,13 @@ class _OfflinePageState extends State height: 130, decoration: BoxDecoration( shape: BoxShape.circle, - color: ColorConstants.primary.withValues(alpha: .08), + color: ColorConstants.primary.withValues( + alpha: .08, + ), boxShadow: [ BoxShadow( - color: ColorConstants.primary.withValues(alpha: - .10 + (.15 * t), + color: ColorConstants.primary.withValues( + alpha: .10 + (.15 * t), ), blurRadius: 25 + (18 * t), spreadRadius: 2 + (8 * t), @@ -157,7 +164,7 @@ class _OfflinePageState extends State ); }, child: Icon( - Icons.wifi_off_rounded, + LucideIcons.wifiOff, color: ColorConstants.primary, size: 68, ), diff --git a/lib/views/onboardscreens/Creat_mpin.dart b/lib/views/onboardscreens/Creat_mpin.dart index 469d933..13edefc 100644 --- a/lib/views/onboardscreens/Creat_mpin.dart +++ b/lib/views/onboardscreens/Creat_mpin.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; @@ -226,7 +227,7 @@ class _CreateMpinBodyState extends State<_CreateMpinBody> { Row( children: [ Icon( - Icons.check_circle_rounded, + LucideIcons.circleCheck, size: 14.sp, color: ColorConstants.acceptGreen, ), @@ -235,7 +236,7 @@ class _CreateMpinBodyState extends State<_CreateMpinBody> { 'Matches', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/onboardscreens/Mpin.dart b/lib/views/onboardscreens/Mpin.dart index 60dfd6a..38517ac 100644 --- a/lib/views/onboardscreens/Mpin.dart +++ b/lib/views/onboardscreens/Mpin.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; import 'package:miler/views/onboardscreens/otp_page.dart'; import 'package:miler/views/onboardscreens/auth_scaffold.dart'; -import 'package:miler/widget/Bottom_page.dart'; +import 'package:miler/helpers/rider_shell.dart'; import 'package:miler/controllers/auth.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -57,6 +58,15 @@ class _MpinViewState extends State<_MpinView> { bool isVerifying = false; bool _rejected = false; + + /// What actually went wrong, from [AuthController.lastPinFailure]. + /// + /// This screen used to hard-code "Incorrect MPIN. Try again." for every + /// failure the controller reported — including the ones that never reached + /// the server. A rider whose sign-in was dying on a device-id lookup was told + /// his PIN was wrong, so the one thing he could report was the one thing that + /// was not true. Whatever the controller knows, the screen now says. + String? _failure; String _riderName = ''; @override @@ -101,7 +111,10 @@ class _MpinViewState extends State<_MpinView> { } setState(() { - if (_rejected) _rejected = false; + if (_rejected) { + _rejected = false; + _failure = null; + } }); if (_isComplete) { @@ -117,7 +130,12 @@ class _MpinViewState extends State<_MpinView> { c.clear(); } _focusNodes[0].requestFocus(); - if (mounted) setState(() => _rejected = true); + if (mounted) { + setState(() { + _rejected = true; + _failure = _auth.lastPinFailure; + }); + } } Future _submitMpin() async { @@ -130,14 +148,27 @@ class _MpinViewState extends State<_MpinView> { ); if (!mounted) return; - setState(() => isVerifying = false); + setState(() { + isVerifying = false; + // Say it on the screen, not only inside a sheet the rider dismisses. + if (!ok) { + _rejected = true; + _failure = _auth.lastPinFailure; + } + }); if (ok) { // Straight to the app. There used to be a "Go on duty" gate between the // PIN and the tabs, which made every launch a two-step sign-in for a // decision the rider can make — and change — from the switch in Home's // app bar at any moment of the shift. - Get.offAll(() => const BottomPage()); + // + // Which app depends on the tenant the login just resolved: `loginParsed` + // persists `tenantid`/`tenantname` and calls `TenantController.load()` + // before returning, so by this line the answer is settled. A delivery + // rider lands in the ported Xpress flow, everyone else in the parcel + // dashboard. See [riderShell]. + Get.offAll(() => riderShell()); } } @@ -161,7 +192,7 @@ class _MpinViewState extends State<_MpinView> { children: [ AuthPrimaryButton( label: 'Unlock', - icon: Icons.lock_open_rounded, + icon: LucideIcons.lockOpen, loading: isVerifying, onPressed: isVerifying || !_isComplete ? null : _submitMpin, ), @@ -190,7 +221,9 @@ class _MpinViewState extends State<_MpinView> { // rejected and the boxes were reset — otherwise they empty themselves // with no stated reason. AuthInlineError( - message: _rejected ? 'Incorrect MPIN. Try again.' : null, + message: _rejected + ? (_failure ?? 'Incorrect MPIN. Try again.') + : null, ), ], ); diff --git a/lib/views/onboardscreens/Sign_in.dart b/lib/views/onboardscreens/Sign_in.dart index 696e15e..0945623 100644 --- a/lib/views/onboardscreens/Sign_in.dart +++ b/lib/views/onboardscreens/Sign_in.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -239,7 +240,7 @@ class _SignInState extends State { cursorColor: ColorConstants.primary, style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: 1.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -258,7 +259,7 @@ class _SignInState extends State { floatingLabelBehavior: FloatingLabelBehavior.always, labelStyle: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.1, color: error ? ColorConstants.errorRed @@ -271,7 +272,7 @@ class _SignInState extends State { hintStyle: TextStyle( color: ColorConstants.secondaryText, fontSize: 18.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, letterSpacing: 1.2, fontFamily: FontConstants.fontFamily, ), @@ -292,7 +293,7 @@ class _SignInState extends State { '+91', style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -312,7 +313,7 @@ class _SignInState extends State { ? Padding( padding: EdgeInsets.only(right: 14.w), child: Icon( - Icons.check_circle_rounded, + LucideIcons.circleCheck, size: 20.sp, color: ColorConstants.acceptGreen, ), @@ -357,7 +358,7 @@ class _SignInState extends State { text: 'Terms of Service', style: TextStyle( color: ColorConstants.slateText, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, decoration: TextDecoration.underline, decorationColor: ColorConstants.borderStrong, fontFamily: FontConstants.fontFamily, @@ -369,7 +370,7 @@ class _SignInState extends State { text: 'Privacy Policy', style: TextStyle( color: ColorConstants.slateText, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, decoration: TextDecoration.underline, decorationColor: ColorConstants.borderStrong, fontFamily: FontConstants.fontFamily, @@ -418,7 +419,9 @@ class _IndiaFlag extends StatelessWidget { borderRadius: BorderRadius.circular(2.5.r), child: Column( children: [ - const Expanded(child: ColoredBox(color: _saffron, child: SizedBox.expand())), + const Expanded( + child: ColoredBox(color: _saffron, child: SizedBox.expand()), + ), Expanded( child: Container( color: Colors.white, @@ -433,7 +436,9 @@ class _IndiaFlag extends StatelessWidget { ), ), ), - const Expanded(child: ColoredBox(color: _green, child: SizedBox.expand())), + const Expanded( + child: ColoredBox(color: _green, child: SizedBox.expand()), + ), ], ), ), diff --git a/lib/views/onboardscreens/auth_scaffold.dart b/lib/views/onboardscreens/auth_scaffold.dart index 6084f4c..4719586 100644 --- a/lib/views/onboardscreens/auth_scaffold.dart +++ b/lib/views/onboardscreens/auth_scaffold.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -50,7 +51,7 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart'; abstract final class AuthType { static TextStyle get title => TextStyle( fontSize: 27.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.7, height: 1.15, color: ColorConstants.slateText, @@ -67,7 +68,7 @@ abstract final class AuthType { static TextStyle get fieldLabel => TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 0.5, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -75,7 +76,7 @@ abstract final class AuthType { static TextStyle get meta => TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, height: 1.45, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -168,7 +169,7 @@ class AuthSteps extends StatelessWidget { 'STEP $step OF $total', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 1.0, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -188,7 +189,9 @@ class AuthSteps extends StatelessWidget { color: i <= step ? ColorConstants.primary : ColorConstants.borderStrong, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), ), ), ), @@ -278,7 +281,7 @@ class AuthScaffold extends StatelessWidget { onTap: onBack!, semanticLabel: 'Back', child: Icon( - Icons.arrow_back_rounded, + LucideIcons.arrowLeft, size: 22.sp, color: ColorConstants.slateText, ), @@ -385,7 +388,7 @@ class AuthScaffold extends StatelessWidget { onTap: onBack!, semanticLabel: 'Back', child: Icon( - Icons.arrow_back_rounded, + LucideIcons.arrowLeft, size: 22.sp, color: ColorConstants.onAccent, ), @@ -513,7 +516,8 @@ class AuthBrandBanner extends StatelessWidget { duration: DesignConstants.motionState, curve: Curves.easeOutCubic, width: double.infinity, - height: (typing ? _typingHeight : _restHeight).h + + height: + (typing ? _typingHeight : _restHeight).h + MediaQuery.paddingOf(context).top, color: ColorConstants.primary, child: ClipRect( @@ -526,8 +530,16 @@ class AuthBrandBanner extends StatelessWidget { // ornament. Bled off both edges and held at 7% white: at that // weight they read as texture on the brand colour and never as // objects the rider might try to tap. - Positioned(left: -78.w, top: -34.h, child: const _BrandRing(size: 196)), - Positioned(right: -92.w, bottom: -78.h, child: const _BrandRing(size: 244)), + Positioned( + left: -78.w, + top: -34.h, + child: const _BrandRing(size: 196), + ), + Positioned( + right: -92.w, + bottom: -78.h, + child: const _BrandRing(size: 244), + ), Center( child: Padding( @@ -566,7 +578,7 @@ class AuthBrandBanner extends StatelessWidget { 'MILER · RIDER APP', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: 1.6, color: ColorConstants.onAccent.withValues(alpha: 0.72), fontFamily: FontConstants.fontFamily, @@ -667,7 +679,7 @@ class AuthPrimaryButton extends StatelessWidget { const AuthPrimaryButton({ super.key, required this.label, - this.icon = Icons.arrow_forward_rounded, + this.icon = LucideIcons.arrowRight, this.loading = false, required this.onPressed, }); @@ -719,7 +731,7 @@ class AuthPrimaryButton extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.2, fontFamily: FontConstants.fontFamily, ), @@ -774,7 +786,7 @@ class AuthTextAction extends StatelessWidget { label, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: color, fontFamily: FontConstants.fontFamily, ), @@ -812,7 +824,7 @@ class AuthInlineError extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.error_outline_rounded, + LucideIcons.circleAlert, size: 15.sp, color: ColorConstants.errorRed, ), @@ -823,7 +835,7 @@ class AuthInlineError extends StatelessWidget { style: TextStyle( color: ColorConstants.errorRed, fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, height: 1.35, fontFamily: FontConstants.fontFamily, ), @@ -865,31 +877,65 @@ class AuthCodeRow extends StatelessWidget { this.hasError = false, }); + /// ── The boxes stopped stretching ── + /// + /// Each box was `Expanded` inside an `AspectRatio`, so four of them divided + /// the *whole* content width between themselves: on a 390pt phone that is a + /// **79×83pt** box, four of them, filling the screen edge to edge. A PIN + /// field that size reads as a keypad rather than as an input — the digits + /// float in the middle of a large empty square, the group dominates a screen + /// whose only job is four taps, and it gets worse on a tablet, where the + /// boxes keep growing with the window. + /// + /// Every app that does this well — the ones a rider already uses to bank and + /// to sign in — sizes the box to the *digit*, not to the screen, and centres + /// the group. 56pt is a comfortable target well above the 44pt floor, and + /// four of them plus their gaps come to 260 of the 390 available, so the + /// group reads as one control with air around it instead of a wall. + /// + /// The size is a *preference*, not a constant: [LayoutBuilder] caps it at + /// whatever the width actually allows, so a 6-digit code on a 320pt phone + /// shrinks to fit rather than overflowing. It can get smaller than preferred; + /// it can never get bigger. @override Widget build(BuildContext context) { final count = controllers.length; - // Taller boxes when there are fewer of them: a 4-digit PIN gets room to - // feel deliberate, a 6-digit code has to stay inside the width. - final ratio = count > 4 ? 1.20 : 1.05; + final double gap = MilerCodeBox.gap(context).w * (count > 4 ? 1 : 1.5); - return Row( - children: [ - for (var i = 0; i < count; i++) ...[ - if (i > 0) SizedBox(width: MilerCodeBox.gap(context).w), - Expanded( - child: AspectRatio( - aspectRatio: 1 / ratio, - child: _Box( - controller: controllers[i], - node: nodes[i], - obscure: obscure, - hasError: hasError, - onChanged: (v) => onChanged(v, i), + // A 4-digit PIN gets the full target; a 6-digit code starts smaller so it + // is not relying on the cap to be legible on a narrow phone. + final double preferred = (count > 4 ? 46 : 56).w; + + return LayoutBuilder( + builder: (context, constraints) { + final double available = constraints.maxWidth - gap * (count - 1); + final double box = preferred < available / count + ? preferred + : available / count; + // Barely taller than it is wide. A tall box puts the digit in the + // middle of empty space; this reads as a field. + final double height = box * 1.12; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < count; i++) ...[ + if (i > 0) SizedBox(width: gap), + SizedBox( + width: box, + height: height, + child: _Box( + controller: controllers[i], + node: nodes[i], + obscure: obscure, + hasError: hasError, + onChanged: (v) => onChanged(v, i), + ), ), - ), - ), - ], - ], + ], + ], + ); + }, ); } } diff --git a/lib/views/onboardscreens/otp_page.dart b/lib/views/onboardscreens/otp_page.dart index 18ccce0..82e2350 100644 --- a/lib/views/onboardscreens/otp_page.dart +++ b/lib/views/onboardscreens/otp_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; @@ -203,7 +204,7 @@ class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill { TextSpan( text: _maskedPhone, style: TextStyle( - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -241,7 +242,7 @@ class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill { child: canResend ? AuthTextAction( label: 'Send a new code', - icon: Icons.refresh_rounded, + icon: LucideIcons.rotateCw, onPressed: () async { setState(() => _error = null); _startTimer(); diff --git a/lib/views/updatescreen/UpdateScreen.dart b/lib/views/updatescreen/UpdateScreen.dart index bd90e11..4458ee9 100644 --- a/lib/views/updatescreen/UpdateScreen.dart +++ b/lib/views/updatescreen/UpdateScreen.dart @@ -9,7 +9,7 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:new_version_plus/new_version_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/views/introscreens/introscreen.dart'; -import 'package:miler/widget/Bottom_page.dart'; +import 'package:miler/helpers/rider_shell.dart'; class UpdateScreen extends StatefulWidget { final bool mIsForceUpdate; @@ -86,8 +86,9 @@ class _UpdateScreenState extends State if (!mounted) return; if (!isLoggedOut && savedUserId != null && savedUserId > 0) { - // No duty gate — see the note in `app_bootstrap.dart`. - Get.offAll(() => const BottomPage()); + // No duty gate — see the note in `app_bootstrap.dart`. Parcel or the + // ported delivery dashboard, per tenant — see [riderShell]. + Get.offAll(() => riderShell()); } else { Get.offAll(() => Introscreen()); } @@ -152,7 +153,7 @@ class _UpdateScreenState extends State "A New Update is Available!", style: TextStyle( fontSize: h * 0.025, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), diff --git a/lib/widget/Bottom_page.dart b/lib/widget/Bottom_page.dart index 0344cb4..ff89b95 100644 --- a/lib/widget/Bottom_page.dart +++ b/lib/widget/Bottom_page.dart @@ -1,5 +1,6 @@ import 'dart:ui' show ImageFilter, lerpDouble; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -10,6 +11,7 @@ import 'package:miler/views/Dashboard/profile/Profilepage.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/data/service_profile.dart'; class BottomPage extends StatefulWidget { final int initialIndex; @@ -162,19 +164,30 @@ class _BottomPageState extends State with TickerProviderStateMixin { static const double _compactAfter = 24; static const double _expandAfter = 6; - static const _navItems = [ - _NavItem(Icons.home_rounded, Icons.home_outlined, 'Home'), + /// ── The work tab is named by the line the rider is on ── + /// + /// The two rider apps this one replaces called a job by two different names: + /// **booking** on the parcel side, **delivery** on the meal side. Merging them + /// did not make one of those wrong — a rider collecting a consignment for the + /// hub is not delivering it, and a rider handing somebody lunch has not taken + /// a booking. So the tab reads the noun off the profile instead of picking a + /// winner, and a rider (who only ever belongs to one line) sees the word he + /// already uses. See [ServiceProfile.jobNoun]. + /// + /// No longer `const`: the label is resolved per build, not baked in. + static List<_NavItem> get _navItems => [ + const _NavItem(LucideIcons.house, LucideIcons.house, 'Home'), _NavItem( - Icons.local_shipping_rounded, - Icons.local_shipping_outlined, - 'Bookings', + LucideIcons.truck, + LucideIcons.truck, + ServiceProfile.active.workTabLabel, ), // Was Earnings. A pay figure is a once-or-twice-a-day read and it did not // need a permanent quarter of the bar — it opens from Account now. What the // rider glances at between stops is what he has already finished, which // used to sit on Home clogging the list he decides from. - _NavItem(Icons.history_rounded, Icons.history_outlined, 'Activity'), - _NavItem(Icons.person_rounded, Icons.person_outline, 'Account'), + const _NavItem(LucideIcons.history, LucideIcons.history, 'Activity'), + const _NavItem(LucideIcons.user, LucideIcons.user, 'Account'), ]; @override @@ -544,7 +557,7 @@ class _NavBarItem extends StatelessWidget { fontSize: 10.5.sp, fontWeight: FontWeight.lerp( FontWeight.w500, - FontWeight.w700, + FontWeight.w600, lit, ), fontFamily: FontConstants.fontFamily, diff --git a/lib/xpress/Models/Message/GetSms.dart b/lib/xpress/Models/Message/GetSms.dart new file mode 100644 index 0000000..87bae3c --- /dev/null +++ b/lib/xpress/Models/Message/GetSms.dart @@ -0,0 +1,78 @@ +class Sms { + int? code; + SmsDetails? details; + String? message; + bool? status; + + Sms({this.code, this.details, this.message, this.status}); + + factory Sms.fromJson(Map json) { + return Sms( + code: json['code'] as int?, + details: json['details'] != null + ? SmsDetails.fromJson(json['details'] as Map) + : null, + message: json['message'] as String?, + status: json['status'] as bool?, + ); + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + if (details != null) { + data['details'] = details!.toJson(); + } + data['message'] = message; + data['status'] = status; + return data; + } +} + +class SmsDetails { + int? providerId; + int? templateTypeId; + String? templateName; + int? templateId; + String? providerApi; + String? content; + int? defaultProvider; + int? passkey; + + SmsDetails({ + this.providerId, + this.templateTypeId, + this.templateName, + this.templateId, + this.providerApi, + this.content, + this.defaultProvider, + this.passkey, + }); + + factory SmsDetails.fromJson(Map json) { + return SmsDetails( + providerId: json['providerid'] as int?, + templateTypeId: json['templatetypeid'] as int?, + templateName: json['templatename'] as String?, + templateId: json['templateid'] as int?, + providerApi: json['providerapi'] as String?, + content: json['content'] as String?, + defaultProvider: json['defaultprovider'] as int?, + passkey: json['passkey'] as int?, + ); + } + + Map toJson() { + final Map data = {}; + data['providerid'] = providerId; + data['templatetypeid'] = templateTypeId; + data['templatename'] = templateName; + data['templateid'] = templateId; + data['providerapi'] = providerApi; + data['content'] = content; + data['defaultprovider'] = defaultProvider; + data['passkey'] = passkey; + return data; + } +} diff --git a/lib/xpress/Models/Offline/Offline.dart b/lib/xpress/Models/Offline/Offline.dart new file mode 100644 index 0000000..82f58c1 --- /dev/null +++ b/lib/xpress/Models/Offline/Offline.dart @@ -0,0 +1,175 @@ +class offline { + int? userid; + String? authname; + int? configid; + int? authmode; + int? roleid; + String? firstname; + String? lastname; + String? fullname; + String? password; + String? email; + String? contactno; + String? address; + String? suburb; + String? city; + String? state; + String? postcode; + String? userfcmtoken; + int? pin; + int? partnerid; + String? identificationno; + String? vehiclename; + String? vehicleno; + String? licenseno; + String? insoranceno; + String? insurancedate; + int? shiftid; + String? starttime; + String? endtime; + int? shifthours; + double? basefare; + double? additionalcharges; + int? orders; + double? fuelcharge; + String? logdate; + int? applocationid; + String? applocation; + int? logseconds; + int? riderid; + String? status; + int? tenantid; + + offline({ + this.userid, + this.authname, + this.configid, + this.authmode, + this.roleid, + this.firstname, + this.lastname, + this.fullname, + this.password, + this.email, + this.contactno, + this.address, + this.suburb, + this.city, + this.state, + this.postcode, + this.userfcmtoken, + this.pin, + this.partnerid, + this.identificationno, + this.vehiclename, + this.vehicleno, + this.licenseno, + this.insoranceno, + this.insurancedate, + this.shiftid, + this.starttime, + this.endtime, + this.shifthours, + this.basefare, + this.additionalcharges, + this.orders, + this.fuelcharge, + this.logdate, + this.applocationid, + this.applocation, + this.logseconds, + this.riderid, + this.status, + this.tenantid, + }); + + factory offline.fromJson(Map json) { + return offline( + userid: json['userid'] as int?, + authname: json['authname'] as String?, + configid: json['configid'] as int?, + authmode: json['authmode'] as int?, + roleid: json['roleid'] as int?, + firstname: json['firstname'] as String?, + lastname: json['lastname'] as String?, + fullname: json['fullname'] as String?, + password: json['password'] as String?, + email: json['email'] as String?, + contactno: json['contactno'] as String?, + address: json['address'] as String?, + suburb: json['suburb'] as String?, + city: json['city'] as String?, + state: json['state'] as String?, + postcode: json['postcode'] as String?, + userfcmtoken: json['userfcmtoken'] as String?, + pin: json['pin'] as int?, + partnerid: json['partnerid'] as int?, + identificationno: json['identificationno'] as String?, + vehiclename: json['vehiclename'] as String?, + vehicleno: json['vehicleno'] as String?, + licenseno: json['licenseno'] as String?, + insoranceno: json['insoranceno'] as String?, + insurancedate: json['insurancedate'] as String?, + shiftid: json['shiftid'] as int?, + starttime: json['starttime'] as String?, + endtime: json['endtime'] as String?, + shifthours: json['shifthours'] as int?, + basefare: (json['basefare'] as num?)?.toDouble(), + additionalcharges: (json['additionalcharges'] as num?)?.toDouble(), + orders: json['orders'] as int?, + fuelcharge: (json['fuelcharge'] as num?)?.toDouble(), + logdate: json['logdate'] as String?, + applocationid: json['applocationid'] as int?, + applocation: json['applocation'] as String?, + logseconds: json['logseconds'] as int?, + riderid: json['riderid'] as int?, + status: json['status'] as String?, + tenantid: json['tenantid'] as int?, + ); + } + + Map toJson() { + final Map data = {}; + data['userid'] = userid; + data['authname'] = authname; + data['configid'] = configid; + data['authmode'] = authmode; + data['roleid'] = roleid; + data['firstname'] = firstname; + data['lastname'] = lastname; + data['fullname'] = fullname; + data['password'] = password; + data['email'] = email; + data['contactno'] = contactno; + data['address'] = address; + data['suburb'] = suburb; + data['city'] = city; + data['state'] = state; + data['postcode'] = postcode; + data['userfcmtoken'] = userfcmtoken; + data['pin'] = pin; + data['partnerid'] = partnerid; + data['identificationno'] = identificationno; + data['vehiclename'] = vehiclename; + data['vehicleno'] = vehicleno; + data['licenseno'] = licenseno; + data['insoranceno'] = insoranceno; + data['insurancedate'] = insurancedate; + data['shiftid'] = shiftid; + data['starttime'] = starttime; + data['endtime'] = endtime; + data['shifthours'] = shifthours; + data['basefare'] = basefare; + data['additionalcharges'] = additionalcharges; + data['orders'] = orders; + data['fuelcharge'] = fuelcharge; + data['logdate'] = logdate; + data['applocationid'] = applocationid; + data['applocation'] = applocation; + data['logseconds'] = logseconds; + data['riderid'] = riderid; + data['status'] = status; + data['tenantid'] = tenantid; + return data; + } +} diff --git a/lib/xpress/Models/Orders/Orders.dart b/lib/xpress/Models/Orders/Orders.dart new file mode 100644 index 0000000..d242727 --- /dev/null +++ b/lib/xpress/Models/Orders/Orders.dart @@ -0,0 +1,283 @@ +class Orders { + int? deliveryid; + int? orderheaderid; + int? applocationid; + int? configid; + int? partnerid; + int? tenantid; + int? moduleid; + int? locationid; + int? categoryid; + int? userid; + int? subcategoryid; + String? orderid; + String? deliverydate; + String? orderstatus; + String? assigntime; + String? starttime; + String? arrivaltime; + String? pickuptime; + String? deliverytime; + String? canceltime; + int? itemcount; + double? orderamount; + int? customerid; + String? pickupcustomer; + String? pickupcontactno; + String? pickupaddress; + String? pickuplocation; + int? pickuplocationid; + String? pickuplat; + String? pickuplon; + int? deliverycustomerid; + int? deliverylocationid; + String? deliverycustomer; + String? deliverycontactno; + String? deliveryaddress; + String? deliverylocation; + String? droplat; + String? droplon; + String? deliverylat; + String? deliverylong; + double? deliverycharges; + double? deliveryamt; + String? deliverytype; + String? notes; + String? ordernotes; + String? riderslat; + String? riderslon; + int? firstmilekm; + double? firstmilecharges; + double? lastmilecharges; + double? ridercharges; + String? kms; + String? actualkms; + int? paymenttype; + String? tenantname; + String? tenantcontactno; + String? tenanttoken; + String? tenantsuburb; + String? tenantcity; + String? locationname; + String? locationcontactno; + String? locationsuburb; + String? ridername; + String? userfcmtoken; + int? queueid; + int? smsdelivery; + bool startStatus; + + Orders({ + this.deliveryid, + this.orderheaderid, + this.applocationid, + this.configid, + this.partnerid, + this.tenantid, + this.moduleid, + this.locationid, + this.categoryid, + this.userid, + this.subcategoryid, + this.orderid, + this.deliverydate, + this.orderstatus, + this.assigntime, + this.starttime, + this.arrivaltime, + this.pickuptime, + this.deliverytime, + this.canceltime, + this.itemcount, + this.orderamount, + this.customerid, + this.pickupcustomer, + this.pickupcontactno, + this.pickupaddress, + this.pickuplocation, + this.pickuplocationid, + this.pickuplat, + this.pickuplon, + this.deliverycustomerid, + this.deliverylocationid, + this.deliverycustomer, + this.deliverycontactno, + this.deliveryaddress, + this.deliverylocation, + this.droplat, + this.droplon, + this.deliverylat, + this.deliverylong, + this.deliverycharges, + this.deliveryamt, + this.deliverytype, + this.notes, + this.ordernotes, + this.riderslat, + this.riderslon, + this.firstmilekm, + this.firstmilecharges, + this.lastmilecharges, + this.ridercharges, + this.kms, + this.actualkms, + this.paymenttype, + this.tenantname, + this.tenantcontactno, + this.tenanttoken, + this.tenantsuburb, + this.tenantcity, + this.locationname, + this.locationcontactno, + this.locationsuburb, + this.ridername, + this.userfcmtoken, + this.queueid, + this.smsdelivery, + this.startStatus = false, + }); + + factory Orders.fromJson(Map json) { + return Orders( + deliveryid: json['deliveryid'] as int?, + orderheaderid: json['orderheaderid'] as int?, + applocationid: json['applocationid'] as int?, + configid: json['configid'] as int?, + partnerid: json['partnerid'] as int?, + tenantid: json['tenantid'] as int?, + moduleid: json['moduleid'] as int?, + locationid: json['locationid'] as int?, + categoryid: json['categoryid'] as int?, + userid: json['userid'] as int?, + subcategoryid: json['subcategoryid'] as int?, + orderid: json['orderid'] as String?, + deliverydate: json['deliverydate'] as String?, + orderstatus: json['orderstatus'] as String?, + assigntime: json['assigntime'] as String?, + starttime: json['starttime'] as String?, + arrivaltime: json['arrivaltime'] as String?, + pickuptime: json['pickuptime'] as String?, + deliverytime: json['deliverytime'] as String?, + canceltime: json['canceltime'] as String?, + itemcount: json['itemcount'] as int?, + orderamount: (json['orderamount'] as num?)?.toDouble(), + customerid: json['customerid'] as int?, + pickupcustomer: json['pickupcustomer'] as String?, + pickupcontactno: json['pickupcontactno'] as String?, + pickupaddress: json['Pickupaddress'] as String?, + pickuplocation: json['pickuplocation'] as String?, + pickuplocationid: json['pickuplocationid'] as int?, + pickuplat: json['pickuplat'] as String?, + pickuplon: json['pickuplon'] as String?, + deliverycustomerid: json['deliverycustomerid'] as int?, + deliverylocationid: json['deliverylocationid'] as int?, + deliverycustomer: json['deliverycustomer'] as String?, + deliverycontactno: json['deliverycontactno'] as String?, + deliveryaddress: json['deliveryaddress'] as String?, + deliverylocation: json['deliverylocation'] as String?, + droplat: json['droplat'] as String?, + droplon: json['droplon'] as String?, + deliverylat: json['deliverylat'] as String?, + deliverylong: json['deliverylong'] as String?, + deliverycharges: (json['deliverycharges'] as num?)?.toDouble(), + deliveryamt: (json['deliveryamt'] as num?)?.toDouble(), + deliverytype: json['deliverytype'] as String?, + notes: json['notes'] as String?, + ordernotes: json['ordernotes'] as String?, + riderslat: json['riderslat'] as String?, + riderslon: json['riderslon'] as String?, + firstmilekm: json['firstmilekm'] as int?, + firstmilecharges: (json['firstmilecharges'] as num?)?.toDouble(), + lastmilecharges: (json['lastmilecharges'] as num?)?.toDouble(), + ridercharges: (json['ridercharges'] as num?)?.toDouble(), + kms: json['kms'] as String?, + actualkms: json['actualkms'] as String?, + paymenttype: json['paymenttype'] as int?, + tenantname: json['tenantname'] as String?, + tenantcontactno: json['tenantcontactno'] as String?, + tenanttoken: json['tenanttoken'] as String?, + tenantsuburb: json['tenantsuburb'] as String?, + tenantcity: json['tenantcity'] as String?, + locationname: json['locationname'] as String?, + locationcontactno: json['locationcontactno'] as String?, + locationsuburb: json['locationsuburb'] as String?, + ridername: json['ridername'] as String?, + userfcmtoken: json['userfcmtoken'] as String?, + queueid: json['queueid'] as int?, + smsdelivery: json['smsdelivery'] as int?, + startStatus: json['startStatus'] as bool? ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['deliveryid'] = deliveryid; + data['orderheaderid'] = orderheaderid; + data['applocationid'] = applocationid; + data['configid'] = configid; + data['partnerid'] = partnerid; + data['tenantid'] = tenantid; + data['moduleid'] = moduleid; + data['locationid'] = locationid; + data['categoryid'] = categoryid; + data['userid'] = userid; + data['subcategoryid'] = subcategoryid; + data['orderid'] = orderid; + data['deliverydate'] = deliverydate; + data['orderstatus'] = orderstatus; + data['assigntime'] = assigntime; + data['starttime'] = starttime; + data['arrivaltime'] = arrivaltime; + data['pickuptime'] = pickuptime; + data['deliverytime'] = deliverytime; + data['canceltime'] = canceltime; + data['itemcount'] = itemcount; + data['orderamount'] = orderamount; + data['customerid'] = customerid; + data['pickupcustomer'] = pickupcustomer; + data['pickupcontactno'] = pickupcontactno; + data['Pickupaddress'] = pickupaddress; + data['pickuplocation'] = pickuplocation; + data['pickuplocationid'] = pickuplocationid; + data['pickuplat'] = pickuplat; + data['pickuplon'] = pickuplon; + data['deliverycustomerid'] = deliverycustomerid; + data['deliverylocationid'] = deliverylocationid; + data['deliverycustomer'] = deliverycustomer; + data['deliverycontactno'] = deliverycontactno; + data['deliveryaddress'] = deliveryaddress; + data['deliverylocation'] = deliverylocation; + data['droplat'] = droplat; + data['droplon'] = droplon; + data['deliverylat'] = deliverylat; + data['deliverylong'] = deliverylong; + data['deliverycharges'] = deliverycharges; + data['deliveryamt'] = deliveryamt; + data['deliverytype'] = deliverytype; + data['notes'] = notes; + data['ordernotes'] = ordernotes; + data['riderslat'] = riderslat; + data['riderslon'] = riderslon; + data['firstmilekm'] = firstmilekm; + data['firstmilecharges'] = firstmilecharges; + data['lastmilecharges'] = lastmilecharges; + data['ridercharges'] = ridercharges; + data['kms'] = kms; + data['actualkms'] = actualkms; + data['paymenttype'] = paymenttype; + data['tenantname'] = tenantname; + data['tenantcontactno'] = tenantcontactno; + data['tenanttoken'] = tenanttoken; + data['tenantsuburb'] = tenantsuburb; + data['tenantcity'] = tenantcity; + data['locationname'] = locationname; + data['locationcontactno'] = locationcontactno; + data['locationsuburb'] = locationsuburb; + data['ridername'] = ridername; + data['userfcmtoken'] = userfcmtoken; + data['queueid'] = queueid; + data['smsdelivery'] = smsdelivery; + data['startStatus'] = startStatus; + return data; + } +} diff --git a/lib/xpress/Models/ProductDetails/ProductDetails.dart b/lib/xpress/Models/ProductDetails/ProductDetails.dart new file mode 100644 index 0000000..8d198ef --- /dev/null +++ b/lib/xpress/Models/ProductDetails/ProductDetails.dart @@ -0,0 +1,205 @@ +class ProductDetails { + int? code; + List? products; + String? message; + Pricedetails? pricedetails; + bool? status; + + ProductDetails({ + this.code, + this.products, + this.message, + this.pricedetails, + this.status, + }); + + factory ProductDetails.fromJson(Map json) { + return ProductDetails( + code: json['code'] as int?, + products: json['details'] != null + ? (json['details'] as List) + .map((v) => Product.fromJson(v as Map)) + .toList() + : null, + message: json['message'] as String?, + pricedetails: json['pricedetails'] != null + ? Pricedetails.fromJson(json['pricedetails'] as Map) + : null, + status: json['status'] as bool?, + ); + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + if (products != null) { + data['details'] = products!.map((v) => v.toJson()).toList(); + } + data['message'] = message; + if (pricedetails != null) { + data['pricedetails'] = pricedetails!.toJson(); + } + data['status'] = status; + return data; + } +} + +class Product { + int? orderDetailId; + int? orderHeaderId; + int? tenantId; + int? locationId; + int? productId; + String? productName; + String? productDescription; + int? supplyQty; + int? balanceQty; + int? orderQty; + int? price; + int? unitId; + String? unitName; + int? productAddonId; + int? addonTypeId; + int? productMapId; + int? productVariantId; + String? productAddonDescription; + int? discountId; + String? discountName; + String? discountCode; + String? discountTerms; + int? discountPercentage; + int? discountAmount; + int? landingAmount; + int? taxPercentage; + int? taxAmount; + int? productSumPrice; + String? itemStatus; + String? delivered; + int? orderAmount; + String? productImage; + + Product({ + this.orderDetailId, + this.orderHeaderId, + this.tenantId, + this.locationId, + this.productId, + this.productName, + this.productDescription, + this.supplyQty, + this.balanceQty, + this.orderQty, + this.price, + this.unitId, + this.unitName, + this.productAddonId, + this.addonTypeId, + this.productMapId, + this.productVariantId, + this.productAddonDescription, + this.discountId, + this.discountName, + this.discountCode, + this.discountTerms, + this.discountPercentage, + this.discountAmount, + this.landingAmount, + this.taxPercentage, + this.taxAmount, + this.productSumPrice, + this.itemStatus, + this.delivered, + this.orderAmount, + this.productImage, + }); + + factory Product.fromJson(Map json) { + return Product( + orderDetailId: json['orderdetailid'] as int?, + orderHeaderId: json['orderheaderid'] as int?, + tenantId: json['tenantid'] as int?, + locationId: json['locationid'] as int?, + productId: json['productid'] as int?, + productName: json['productname'] as String?, + productDescription: json['productdescription'] as String?, + supplyQty: json['supplyqty'] as int?, + balanceQty: json['balanceqty'] as int?, + orderQty: json['orderqty'] as int?, + price: json['price'] as int?, + unitId: json['unitid'] as int?, + unitName: json['unitname'] as String?, + productAddonId: json['productaddonid'] as int?, + addonTypeId: json['addontypeid'] as int?, + productMapId: json['productmapid'] as int?, + productVariantId: json['productvariantid'] as int?, + productAddonDescription: json['productaddondescription'] as String?, + discountId: json['discountid'] as int?, + discountName: json['discountname'] as String?, + discountCode: json['discountcode'] as String?, + discountTerms: json['discountterms'] as String?, + discountPercentage: json['discountpercentage'] as int?, + discountAmount: json['discountamount'] as int?, + landingAmount: json['landingamount'] as int?, + taxPercentage: json['taxpercentage'] as int?, + taxAmount: json['taxamount'] as int?, + productSumPrice: json['productsumprice'] as int?, + itemStatus: json['itemstatus'] as String?, + delivered: json['delivered'] as String?, + orderAmount: json['orderamount'] as int?, + productImage: json['productimage'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['orderdetailid'] = orderDetailId; + data['orderheaderid'] = orderHeaderId; + data['tenantid'] = tenantId; + data['locationid'] = locationId; + data['productid'] = productId; + data['productname'] = productName; + data['productdescription'] = productDescription; + data['supplyqty'] = supplyQty; + data['balanceqty'] = balanceQty; + data['orderqty'] = orderQty; + data['price'] = price; + data['unitid'] = unitId; + data['unitname'] = unitName; + data['productaddonid'] = productAddonId; + data['addontypeid'] = addonTypeId; + data['productmapid'] = productMapId; + data['productvariantid'] = productVariantId; + data['productaddondescription'] = productAddonDescription; + data['discountid'] = discountId; + data['discountname'] = discountName; + data['discountcode'] = discountCode; + data['discountterms'] = discountTerms; + data['discountpercentage'] = discountPercentage; + data['discountamount'] = discountAmount; + data['landingamount'] = landingAmount; + data['taxpercentage'] = taxPercentage; + data['taxamount'] = taxAmount; + data['productsumprice'] = productSumPrice; + data['itemstatus'] = itemStatus; + data['delivered'] = delivered; + data['orderamount'] = orderAmount; + data['productimage'] = productImage; + return data; + } +} + +class Pricedetails { + int? orderAmount; + + Pricedetails({this.orderAmount}); + + factory Pricedetails.fromJson(Map json) { + return Pricedetails(orderAmount: json['orderamount'] as int?); + } + + Map toJson() { + final Map data = {}; + data['orderamount'] = orderAmount; + return data; + } +} diff --git a/lib/xpress/Models/TenantPrice/Tenant.dart b/lib/xpress/Models/TenantPrice/Tenant.dart new file mode 100644 index 0000000..42b2e3f --- /dev/null +++ b/lib/xpress/Models/TenantPrice/Tenant.dart @@ -0,0 +1,78 @@ +class Tenant { + int? code; + TenantDetails? details; + String? message; + bool? status; + + Tenant({this.code, this.details, this.message, this.status}); + + factory Tenant.fromJson(Map json) { + return Tenant( + code: json['code'] as int?, + details: json['details'] != null + ? TenantDetails.fromJson(json['details'] as Map) + : null, + message: json['message'] as String?, + status: json['status'] as bool?, + ); + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + if (details != null) { + data['details'] = details!.toJson(); + } + data['message'] = message; + data['status'] = status; + return data; + } +} + +class TenantDetails { + int? pricingId; + int? tenantId; + int? locationId; + String? pricingDate; + int? basePrice; + int? pricePerKm; + int? minKm; + int? otherCharges; + + TenantDetails({ + this.pricingId, + this.tenantId, + this.locationId, + this.pricingDate, + this.basePrice, + this.pricePerKm, + this.minKm, + this.otherCharges, + }); + + factory TenantDetails.fromJson(Map json) { + return TenantDetails( + pricingId: json['pricingid'] as int?, + tenantId: json['tenantid'] as int?, + locationId: json['locationid'] as int?, + pricingDate: json['pricingdate'] as String?, + basePrice: json['baseprice'] as int?, + pricePerKm: json['priceperkm'] as int?, + minKm: json['minkm'] as int?, + otherCharges: json['othercharges'] as int?, + ); + } + + Map toJson() { + final Map data = {}; + data['pricingid'] = pricingId; + data['tenantid'] = tenantId; + data['locationid'] = locationId; + data['pricingdate'] = pricingDate; + data['baseprice'] = basePrice; + data['priceperkm'] = pricePerKm; + data['minkm'] = minKm; + data['othercharges'] = otherCharges; + return data; + } +} diff --git a/lib/xpress/Models/User/User.dart b/lib/xpress/Models/User/User.dart new file mode 100644 index 0000000..7ef27c8 --- /dev/null +++ b/lib/xpress/Models/User/User.dart @@ -0,0 +1,129 @@ +class User { + int? userid; + String? authname; + int? configid; + int? authmode; + int? roleid; + String? firstname; + String? lastname; + String? password; + String? email; + String? contactno; + String? address; + String? suburb; + String? city; + String? state; + String? postcode; + String? userfcmtoken; + int? pin; + int? partnerid; + int? tenantid; + String? fullname; + String? tenantname; + String? tenantaddress; + String? tenantcity; + String? tenantpostcode; + String? tenantlat; + String? tenantlong; + int? locationid; + String? locationname; + int? applocationid; + + User({ + this.userid, + this.authname, + this.configid, + this.authmode, + this.roleid, + this.firstname, + this.lastname, + this.password, + this.email, + this.contactno, + this.address, + this.suburb, + this.city, + this.state, + this.postcode, + this.userfcmtoken, + this.pin, + this.partnerid, + this.tenantid, + this.fullname, + this.tenantname, + this.tenantaddress, + this.tenantcity, + this.tenantpostcode, + this.tenantlat, + this.tenantlong, + this.locationid, + this.locationname, + this.applocationid, + }); + + factory User.fromJson(Map json) { + return User( + userid: json['userid'] as int?, + authname: json['authname'] as String?, + configid: json['configid'] as int?, + authmode: json['authmode'] as int?, + roleid: json['roleid'] as int?, + firstname: json['firstname'] as String?, + lastname: json['lastname'] as String?, + password: json['password'] as String?, + email: json['email'] as String?, + contactno: json['contactno'] as String?, + address: json['address'] as String?, + suburb: json['suburb'] as String?, + city: json['city'] as String?, + state: json['state'] as String?, + postcode: json['postcode'] as String?, + userfcmtoken: json['userfcmtoken'] as String?, + pin: json['pin'] as int?, + partnerid: json['partnerid'] as int?, + tenantid: json['tenantid'] as int?, + fullname: json['fullname'] as String?, + tenantname: json['tenantname'] as String?, + tenantaddress: json['tenantaddress'] as String?, + tenantcity: json['tenantcity'] as String?, + tenantpostcode: json['tenantpostcode'] as String?, + tenantlat: json['tenantlat'] as String?, + tenantlong: json['tenantlong'] as String?, + locationid: json['locationid'] as int?, + locationname: json['locationname'] as String?, + applocationid: json['applocationid'] as int?, + ); + } + + Map toJson() => { + 'userid': userid, + 'authname': authname, + 'configid': configid, + 'authmode': authmode, + 'roleid': roleid, + 'firstname': firstname, + 'lastname': lastname, + 'password': password, + 'email': email, + 'contactno': contactno, + 'address': address, + 'suburb': suburb, + 'city': city, + 'state': state, + 'postcode': postcode, + 'userfcmtoken': userfcmtoken, + 'pin': pin, + 'partnerid': partnerid, + 'tenantid': tenantid, + 'fullname': fullname, + 'tenantname': tenantname, + 'tenantaddress': tenantaddress, + 'tenantcity': tenantcity, + 'tenantpostcode': tenantpostcode, + 'tenantlat': tenantlat, + 'tenantlong': tenantlong, + 'locationid': locationid, + 'locationname': locationname, + 'applocationid': applocationid, + }; +} diff --git a/lib/xpress/Models/deliveries/deliveries_models.dart b/lib/xpress/Models/deliveries/deliveries_models.dart new file mode 100644 index 0000000..703b3b5 --- /dev/null +++ b/lib/xpress/Models/deliveries/deliveries_models.dart @@ -0,0 +1,313 @@ +// Consolidated deliveries models + +class DeliveryUpdate { + int? deliveryid; + int? orderheaderid; + int? deliverylocationid; + int? pickuplocationid; + int? smsdelivery; + String? orderstatus; + String? starttime; + String? arrivaltime; + String? pickuptime; + String? deliverytime; + String? canceltime; + String? riderslat; + String? riderslon; + String? pickuplat; + String? pickuplong; + String? deliverylat; + String? deliverylong; + String? address; + String? city; + String? state; + String? suburb; + String? postcode; + String? kms; + String? riderkms; + String? actualkms; + double? deliveryamt; + String? notes; + String? deliverytype; + String? kmcal; + String? feedback; + + DeliveryUpdate({ + this.deliveryid, + this.orderheaderid, + this.deliverylocationid, + this.pickuplocationid, + this.smsdelivery, + this.orderstatus, + this.starttime, + this.arrivaltime, + this.pickuptime, + this.deliverytime, + this.canceltime, + this.riderslat, + this.riderslon, + this.pickuplat, + this.pickuplong, + this.deliverylat, + this.deliverylong, + this.address, + this.city, + this.state, + this.suburb, + this.postcode, + this.kms, + this.riderkms, + this.actualkms, + this.deliveryamt, + this.notes, + this.deliverytype, + this.kmcal, + this.feedback, + }); + + factory DeliveryUpdate.fromJson(Map json) { + return DeliveryUpdate( + deliveryid: json['deliveryid'], + orderheaderid: json['orderheaderid'], + deliverylocationid: json['deliverylocationid'], + pickuplocationid: json['pickuplocationid'], + smsdelivery: json['smsdelivery'], + orderstatus: json['orderstatus'], + starttime: json['starttime'], + arrivaltime: json['arrivaltime'], + pickuptime: json['pickuptime'], + deliverytime: json['deliverytime'], + canceltime: json['canceltime'], + riderslat: json['riderslat'], + riderslon: json['riderslon'], + pickuplat: json['pickuplat'], + pickuplong: json['pickuplong'], + deliverylat: json['deliverylat'], + deliverylong: json['deliverylong'], + address: json['address'], + city: json['city'], + state: json['state'], + suburb: json['suburb'], + postcode: json['postcode'], + kms: json['kms'], + riderkms: json['riderkms'], + actualkms: json['actualkms'], + deliveryamt: (json['deliveryamt'] as num?)?.toDouble(), + notes: json['notes'], + deliverytype: json['deliverytype'], + kmcal: json['kmcal'], + feedback: json['feedback'], + ); + } + + Map toJson() { + final Map data = {}; + data['deliveryid'] = deliveryid; + data['orderheaderid'] = orderheaderid; + data['deliverylocationid'] = deliverylocationid; + data['pickuplocationid'] = pickuplocationid; + data['smsdelivery'] = smsdelivery; + data['orderstatus'] = orderstatus; + data['starttime'] = starttime; + data['arrivaltime'] = arrivaltime; + data['pickuptime'] = pickuptime; + data['deliverytime'] = deliverytime; + data['canceltime'] = canceltime; + data['riderslat'] = riderslat; + data['riderslon'] = riderslon; + data['pickuplat'] = pickuplat; + data['pickuplong'] = pickuplong; + data['deliverylat'] = deliverylat; + data['deliverylong'] = deliverylong; + data['address'] = address; + data['city'] = city; + data['state'] = state; + data['suburb'] = suburb; + data['postcode'] = postcode; + data['kms'] = kms; + data['riderkms'] = riderkms; + data['actualkms'] = actualkms; + data['deliveryamt'] = deliveryamt; + data['notes'] = notes; + data['deliverytype'] = deliverytype; + data['kmcal'] = kmcal; + data['feedback'] = feedback; + return data; + } +} + +class DeliveryLogModel { + int? logid; + int? tenantid; + int? partnerid; + int? locationid; + int? orderheaderid; + int? deliveryid; + int? userid; + String? orderid; + String? logdate; + String? orderstatus; + String? latitude; + String? longitude; + + DeliveryLogModel({ + this.logid, + this.tenantid, + this.partnerid, + this.locationid, + this.orderheaderid, + this.deliveryid, + this.userid, + this.orderid, + this.logdate, + this.orderstatus, + this.latitude, + this.longitude, + }); + + factory DeliveryLogModel.fromJson(Map json) { + return DeliveryLogModel( + logid: json['logid'], + tenantid: json['tenantid'], + partnerid: json['Partnerid'] ?? json['partnerid'], + locationid: json['Locationid'] ?? json['locationid'], + orderheaderid: json['orderheaderid'], + deliveryid: json['deliveryid'], + userid: json['userid'], + orderid: json['orderid'], + logdate: json['logdate'], + orderstatus: json['orderstatus'], + latitude: json['latitude'], + longitude: json['longitude'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['tenantid'] = tenantid; + data['Partnerid'] = partnerid; + data['Locationid'] = locationid; + data['orderheaderid'] = orderheaderid; + data['deliveryid'] = deliveryid; + data['userid'] = userid; + data['orderid'] = orderid; + data['logdate'] = logdate; + data['orderstatus'] = orderstatus; + data['latitude'] = latitude; + data['longitude'] = longitude; + return data; + } +} + +class DeliverySummaryModel { + int? total; + int? created; + int? pending; + int? accepted; + int? picked; + int? delivered; + int? cancelled; + + DeliverySummaryModel({ + this.total, + this.created, + this.pending, + this.accepted, + this.picked, + this.delivered, + this.cancelled, + }); + + factory DeliverySummaryModel.fromJson(Map json) { + return DeliverySummaryModel( + total: json['total'], + created: json['created'], + pending: json['pending'], + accepted: json['accepted'], + picked: json['picked'], + delivered: json['delivered'], + cancelled: json['cancelled'], + ); + } + + Map toJson() { + final Map data = {}; + data['total'] = total; + data['created'] = created; + data['pending'] = pending; + data['accepted'] = accepted; + data['picked'] = picked; + data['delivered'] = delivered; + data['cancelled'] = cancelled; + return data; + } +} + +class DeliveryItem { + int? logid; + String? logdate; + int? tenantid; + int? locationid; + int? orderheaderid; + int? deliveryid; + int? userid; + int? partnerid; + String? orderid; + String? orderstatus; + String? latitude; + String? longitude; + int? logstatus; + + DeliveryItem({ + this.logid, + this.logdate, + this.tenantid, + this.locationid, + this.orderheaderid, + this.deliveryid, + this.userid, + this.partnerid, + this.orderid, + this.orderstatus, + this.latitude, + this.longitude, + this.logstatus, + }); + + factory DeliveryItem.fromJson(Map json) { + return DeliveryItem( + logid: json['logid'], + logdate: json['logdate'], + tenantid: json['tenantid'], + locationid: json['locationid'], + orderheaderid: json['orderheaderid'], + deliveryid: json['deliveryid'], + userid: json['userid'], + partnerid: json['partnerid'], + orderid: json['orderid'], + orderstatus: json['orderstatus'], + latitude: json['latitude'], + longitude: json['longitude'], + logstatus: json['logstatus'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['logdate'] = logdate; + data['tenantid'] = tenantid; + data['locationid'] = locationid; + data['orderheaderid'] = orderheaderid; + data['deliveryid'] = deliveryid; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['orderid'] = orderid; + data['orderstatus'] = orderstatus; + data['latitude'] = latitude; + data['longitude'] = longitude; + data['logstatus'] = logstatus; + return data; + } +} diff --git a/lib/xpress/Models/login/login.dart b/lib/xpress/Models/login/login.dart new file mode 100644 index 0000000..b92227d --- /dev/null +++ b/lib/xpress/Models/login/login.dart @@ -0,0 +1,137 @@ +class Login { + int? code; + String? message; + bool? status; + + int? userid; + String? contactno; + String? devicetype; + int? configid; + String? deviceid; + String? userfcmtoken; + String? authname; + int? authmode; + int? roleid; + String? firstname; + String? lastname; + String? fullname; + String? password; + String? email; + String? address; + String? suburb; + String? city; + String? state; + String? postcode; + int? pin; + int? shiftid; + int? logid; + int? partnerid; + int? tenantid; + int? riderid; + int? deliveryradius; + int? locationid; + int? applocationid; + + Login({ + this.code, + this.message, + this.status, + this.userid, + this.contactno, + this.devicetype, + this.configid, + this.deviceid, + this.userfcmtoken, + this.authname, + this.authmode, + this.roleid, + this.firstname, + this.lastname, + this.fullname, + this.password, + this.email, + this.address, + this.suburb, + this.city, + this.state, + this.postcode, + this.pin, + this.shiftid, + this.logid, + this.partnerid, + this.tenantid, + this.riderid, + this.deliveryradius, + this.locationid, + this.applocationid, + }); + + /// Factory: builds a Login model from JSON (supports both "data" and "details") + factory Login.fromJson(Map json) { + // Safely extract nested data from either "data" or "details" + final Map nested = + (json['details'] ?? json['data'] ?? {}); + + final dynamic topAuthMode = json['authmode']; + final dynamic nestedAuthMode = nested['authmode']; + + return Login( + code: json['code'] is int + ? json['code'] + : int.tryParse('${json['code']}'), + message: json['message']?.toString(), + status: json['status'] is bool ? json['status'] : json['status'] == 1, + userid: json['userid'] ?? nested['userid'], + contactno: + json['contactno']?.toString() ?? nested['contactno']?.toString(), + devicetype: json['devicetype']?.toString(), + configid: json['configid'] ?? nested['configid'], + deviceid: json['deviceid']?.toString(), + userfcmtoken: + json['userfcmtoken']?.toString() ?? + nested['userfcmtoken']?.toString(), + authname: (json['authname'] ?? nested['authname'])?.toString(), + authmode: topAuthMode is int + ? topAuthMode + : (nestedAuthMode is int + ? nestedAuthMode + : int.tryParse('$topAuthMode')), + roleid: nested['roleid'], + firstname: nested['firstname']?.toString(), + lastname: nested['lastname']?.toString(), + fullname: nested['fullname']?.toString(), + password: nested['password']?.toString(), + email: nested['email']?.toString(), + address: nested['address']?.toString(), + suburb: nested['suburb']?.toString(), + city: nested['city']?.toString(), + state: nested['state']?.toString(), + postcode: nested['postcode']?.toString(), + pin: nested['pin'], + shiftid: nested['shiftid'], + logid: nested['logid'], + partnerid: nested['partnerid'], + tenantid: nested['tenantid'], + riderid: nested['riderid'], + deliveryradius: nested['deliveryradius'], + locationid: nested['locationid'] is int + ? nested['locationid'] + : int.tryParse('${nested['locationid'] ?? 0}'), + applocationid: nested['applocationid'] is int + ? nested['applocationid'] + : int.tryParse('${nested['applocationid'] ?? 0}'), + ); + } + + /// Convert this model to JSON for sending to the backend + Map toJson() { + final Map data = {}; + if (contactno != null) data['contactno'] = contactno; + if (devicetype != null) data['devicetype'] = devicetype; + if (configid != null) data['configid'] = configid; + if (deviceid != null) data['deviceid'] = deviceid; + if (userfcmtoken != null) data['userfcmtoken'] = userfcmtoken; + if (pin != null) data['pin'] = pin; + return data; + } +} diff --git a/lib/xpress/Models/notification/notification_models.dart b/lib/xpress/Models/notification/notification_models.dart new file mode 100644 index 0000000..a0200c6 --- /dev/null +++ b/lib/xpress/Models/notification/notification_models.dart @@ -0,0 +1,142 @@ +// Consolidated notification models + +class AdminNotification { + String? priority; + List? registrationIds; + String? accessid; + String? title; + String? body; + String? sound; + + AdminNotification({ + this.priority, + this.registrationIds, + this.accessid, + this.title, + this.body, + this.sound, + }); + + factory AdminNotification.fromJson(Map json) { + return AdminNotification( + priority: json['priority'] as String?, + registrationIds: json['registration_ids']?.cast(), + accessid: json['data']?['accessid'] as String?, + title: json['notification']?['title'] as String?, + body: json['notification']?['body'] as String?, + sound: json['notification']?['sound'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['priority'] = priority; + data['registration_ids'] = registrationIds; + if (accessid != null) { + data['data'] = {'accessid': accessid}; + } + if (title != null || body != null || sound != null) { + data['notification'] = { + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sound != null) 'sound': sound, + }; + } + return data; + } +} + +class NotificationMessage { + String? sender; + String? accessid; + String? priority; + String? to; + String? title; + String? body; + String? sound; + + NotificationMessage({ + this.sender, + this.accessid, + this.priority, + this.to, + this.title, + this.body, + this.sound, + }); + + factory NotificationMessage.fromJson(Map json) { + return NotificationMessage( + sender: json['sender'] as String?, + accessid: json['accessid'] as String?, + priority: json['notification']?['priority'] as String?, + to: json['notification']?['to'] as String?, + title: json['notification']?['notification']?['title'] as String?, + body: json['notification']?['notification']?['body'] as String?, + sound: json['notification']?['notification']?['sound'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['sender'] = sender; + data['accessid'] = accessid; + if (priority != null || + to != null || + title != null || + body != null || + sound != null) { + data['notification'] = { + if (priority != null) 'priority': priority, + if (to != null) 'to': to, + if (title != null || body != null || sound != null) + 'notification': { + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sound != null) 'sound': sound, + }, + }; + } + return data; + } +} + +class RiderNotification { + String? token; + String? title; + String? body; + String? sound; + String? image; + + RiderNotification({ + this.token, + this.title, + this.body, + this.sound, + this.image, + }); + + factory RiderNotification.fromJson(Map json) { + return RiderNotification( + token: json['token'] as String?, + title: json['notification']?['title'] as String?, + body: json['notification']?['body'] as String?, + sound: json['notification']?['sound'] as String?, + image: json['notification']?['image'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['token'] = token; + if (title != null || body != null || sound != null || image != null) { + data['notification'] = { + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sound != null) 'sound': sound, + if (image != null) 'image': image, + }; + } + return data; + } +} diff --git a/lib/xpress/Models/riders/riders_models.dart b/lib/xpress/Models/riders/riders_models.dart new file mode 100644 index 0000000..db41835 --- /dev/null +++ b/lib/xpress/Models/riders/riders_models.dart @@ -0,0 +1,287 @@ +// Consolidated riders models + +class RiderLog { + int? logid; + String? logdate; + int? userid; + int? partnerid; + int? shiftid; + double? shifthours; + String? login; + String? latitude; + double? workhours; + double? shorthours; + int? logstatus; + String? longitude; + double? breakhours; + + int? onduty; + int? tenantid; + int? locationid; + int? applocationid; + String? userfcmtoken; + + RiderLog({ + this.logid, + this.logdate, + this.userid, + this.partnerid, + this.shiftid, + this.shifthours, + this.login, + this.latitude, + this.workhours, + this.shorthours, + this.logstatus, + this.longitude, + this.breakhours, + this.onduty, + this.tenantid, + this.locationid, + this.applocationid, + this.userfcmtoken, + }); + + factory RiderLog.fromJson(Map json) { + return RiderLog( + logid: json['logid'], + logdate: json['logdate'], + userid: json['userid'], + partnerid: json['partnerid'], + shiftid: json['shiftid'], + shifthours: (json['shifthours'] as num?)?.toDouble(), + login: json['login'], + latitude: json['latitude'], + workhours: (json['workhours'] as num?)?.toDouble(), + shorthours: (json['shorthours'] as num?)?.toDouble(), + logstatus: json['logstatus'], + longitude: json['longitude'], + breakhours: (json['breakhours'] as num?)?.toDouble(), + onduty: json['onduty'], + tenantid: json['tenantid'], + locationid: json['locationid'], + applocationid: json['applocationid'], + userfcmtoken: json['userfcmtoken'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['logdate'] = logdate; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['shiftid'] = shiftid; + data['shifthours'] = shifthours; + data['login'] = login; + data['latitude'] = latitude; + data['workhours'] = workhours; + data['shorthours'] = shorthours; + data['logstatus'] = logstatus; + data['longitude'] = longitude; + data['breakhours'] = breakhours; + data['onduty'] = onduty; + data['tenantid'] = tenantid; + data['locationid'] = locationid; + data['applocationid'] = applocationid; + data['userfcmtoken'] = userfcmtoken; + return data; + } +} + +class RiderBreak { + int? breakid; + int? logid; + String? breakdate; + int? userid; + int? partnerid; + int? shiftid; + String? breakstart; + String? breakend; + double? breakhours; + String? latitude; + String? longitude; + + RiderBreak({ + this.breakid, + this.logid, + this.breakdate, + this.userid, + this.partnerid, + this.shiftid, + this.breakstart, + this.breakend, + this.breakhours, + this.latitude, + this.longitude, + }); + + factory RiderBreak.fromJson(Map json) { + return RiderBreak( + breakid: json['breakid'], + logid: json['logid'], + breakdate: json['breakdate'], + userid: json['userid'], + partnerid: json['partnerid'], + shiftid: json['shiftid'], + breakstart: json['breakstart'], + breakend: json['breakend'], + breakhours: (json['breakhours'] as num?)?.toDouble(), + latitude: json['latitude'], + longitude: json['longitude'], + ); + } + + Map toJson() { + final Map data = {}; + data['breakid'] = breakid; + data['logid'] = logid; + data['breakdate'] = breakdate; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['shiftid'] = shiftid; + data['breakstart'] = breakstart; + data['breakend'] = breakend; + data['breakhours'] = breakhours; + data['latitude'] = latitude; + data['longitude'] = longitude; + return data; + } +} + +class RiderLogin { + int? logid; + int? userid; + int? partnerid; + int? shiftid; + String? logdate; + String? login; + double? shifthours; + String? latitude; + String? longitude; + int? onduty; + String? status; // "active" when there are active deliveries, "idle" otherwise + String? username; // Rider display name for rider logs + String? contactno; + int? tenantid; + int? locationid; + int? applocationid; + String? userfcmtoken; + + RiderLogin({ + this.logid, + this.userid, + this.partnerid, + this.shiftid, + this.logdate, + this.login, + this.shifthours, + this.latitude, + this.longitude, + this.onduty, + this.status, + this.username, + + this.contactno, + this.tenantid, + this.locationid, + this.applocationid, + this.userfcmtoken, + }); + + factory RiderLogin.fromJson(Map json) { + return RiderLogin( + logid: json['logid'], + userid: json['userid'], + partnerid: json['partnerid'], + shiftid: json['shiftid'], + logdate: json['logdate'], + login: json['Login'] ?? json['login'], + shifthours: (json['shifthours'] as num?)?.toDouble(), + latitude: json['latitude'], + longitude: json['longitude'], + onduty: json['onduty'], + status: json['status'], + username: json['username'], + contactno: json['contactno'], + tenantid: json['tenantid'], + locationid: json['locationid'], + applocationid: json['applocationid'], + userfcmtoken: json['userfcmtoken'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['shiftid'] = shiftid; + data['logdate'] = logdate; + data['Login'] = login; + data['shifthours'] = shifthours; + data['latitude'] = latitude; + data['longitude'] = longitude; + if (onduty != null) data['onduty'] = onduty; + if (status != null) data['status'] = status; + if (username != null && username!.trim().isNotEmpty) { + data['username'] = username; + } + if (contactno != null) { + data['contactno'] = contactno; + } + data['tenantid'] = tenantid; + data['locationid'] = locationid; + data['applocationid'] = applocationid; + data['userfcmtoken'] = userfcmtoken; + return data; + } +} + +class RiderUpdate { + int? userid; + int? logstatus; + String? logout; + double? workhours; + double? shorthours; + String? latitude; + String? longitude; + int? onduty; + + RiderUpdate({ + this.userid, + this.logstatus, + this.logout, + this.workhours, + this.shorthours, + this.latitude, + this.longitude, + this.onduty, + }); + + factory RiderUpdate.fromJson(Map json) { + return RiderUpdate( + userid: json['userid'], + logstatus: json['logstatus'], + logout: json['Logout'] ?? json['logout'], + workhours: (json['workhours'] as num?)?.toDouble(), + shorthours: (json['shorthours'] as num?)?.toDouble(), + latitude: json['latitude'], + longitude: json['longitude'], + onduty: json['onduty'], + ); + } + + Map toJson() { + final Map data = {}; + data['userid'] = userid; + data['logstatus'] = logstatus; + data['Logout'] = logout; + data['workhours'] = workhours; + data['shorthours'] = shorthours; + data['latitude'] = latitude; + data['longitude'] = longitude; + if (onduty != null) data['onduty'] = onduty; + return data; + } +} diff --git a/lib/xpress/Models/summary/deliverystats.dart b/lib/xpress/Models/summary/deliverystats.dart new file mode 100644 index 0000000..aae8bbd --- /dev/null +++ b/lib/xpress/Models/summary/deliverystats.dart @@ -0,0 +1,25 @@ +class DeliveryStats { + final int today; + final int week; + final int month; + final int total; + final int cancelled; + + DeliveryStats({ + required this.today, + required this.week, + required this.month, + required this.total, + required this.cancelled, + }); + + factory DeliveryStats.fromJson(Map json) { + return DeliveryStats( + today: json['today'] ?? 0, + week: json['week'] ?? 0, + month: json['month'] ?? 0, + total: json['total'] ?? 0, + cancelled: json['cancelled'] ?? 0, + ); + } +} diff --git a/lib/xpress/Models/summary/riderweeklykms.dart b/lib/xpress/Models/summary/riderweeklykms.dart new file mode 100644 index 0000000..9495195 --- /dev/null +++ b/lib/xpress/Models/summary/riderweeklykms.dart @@ -0,0 +1,44 @@ +class RiderWeeklyKms { + final String day; + final double kms; + + RiderWeeklyKms({required this.day, required this.kms}); + + factory RiderWeeklyKms.fromJson(Map json) { + return RiderWeeklyKms( + day: json['day'] ?? '', + kms: double.tryParse('${json['kms'] ?? 0}') ?? 0.0, + ); + } +} + +class RiderWeeklyKmsResponse { + final bool status; + final int code; + final String message; + final double totalKms; + final double overallKms; + final List details; + + RiderWeeklyKmsResponse({ + required this.status, + required this.code, + required this.message, + required this.totalKms, + required this.overallKms, + required this.details, + }); + + factory RiderWeeklyKmsResponse.fromJson(Map json) { + return RiderWeeklyKmsResponse( + status: json['status'] ?? false, + code: json['code'] ?? 0, + message: json['message'] ?? '', + totalKms: double.tryParse('${json['total_kms'] ?? 0}') ?? 0.0, + overallKms: double.tryParse('${json['overall_kms'] ?? 0}') ?? 0.0, + details: (json['details'] as List? ?? []) + .map((e) => RiderWeeklyKms.fromJson(e)) + .toList(), + ); + } +} diff --git a/lib/xpress/Models/supportticket/support_ticket.dart b/lib/xpress/Models/supportticket/support_ticket.dart new file mode 100644 index 0000000..8d0265e --- /dev/null +++ b/lib/xpress/Models/supportticket/support_ticket.dart @@ -0,0 +1,37 @@ +class SupportTicketModel { + final int ridersupportid; + final int userid; + final String category; + final String priority; + final String subject; + final String issue; + final String? image; + final DateTime created; + final DateTime updated; + + SupportTicketModel({ + required this.ridersupportid, + required this.userid, + required this.category, + required this.priority, + required this.subject, + required this.issue, + this.image, + required this.created, + required this.updated, + }); + + factory SupportTicketModel.fromJson(Map json) { + return SupportTicketModel( + ridersupportid: json['ridersupportid'] as int, + userid: json['userid'] as int, + category: json['category'] as String, + priority: json['priority'] as String, + subject: json['subject'] as String, + issue: json['issue'] as String, + image: json['image'] as String?, + created: DateTime.parse(json['created'] as String), + updated: DateTime.parse(json['updated'] as String), + ); + } +} diff --git a/lib/xpress/background/backgroundservice.dart b/lib/xpress/background/backgroundservice.dart new file mode 100644 index 0000000..0cc4bea --- /dev/null +++ b/lib/xpress/background/backgroundservice.dart @@ -0,0 +1,1012 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'dart:math' as math; + +import 'package:miler/xpress/providers/deliverylog/deliverylog_provider.dart'; +import 'package:miler/xpress/providers/notifications/notificationservce.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'package:miler/xpress/utils/kalman_filter.dart'; + +/// Background service for managing active delivery logs +class BackgroundDeliveryLog { + static DoormileKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static final CreateDeliveryLogProvider _logProvider = + CreateDeliveryLogProvider(); + static final http.Client _httpClient = http.Client(); + static final FlutterTts _tts = FlutterTts(); + static final AudioPlayer _proximityPlayer = AudioPlayer(); + + static const double _idleThresholdMeters = 10; // ~5-10m tolerance + static const double _proximityThresholdMeters = + 50.0; // 50 meters for arrival alert + static const String _activeDeliveriesKey = 'active_deliveries'; + static const String _payloadKeyPrefix = 'delivery_payload_'; + static const String _offlineLogKey = 'offline_delivery_logs'; + static const String _proximityAlertKeyPrefix = 'delivery_proximity_alerted_'; + + static bool _isPosting = false; + static bool _ttsReady = false; + static bool _audioReady = false; + + /// Process active deliveries: fetch from API, filter active, and post logs + static Future processActiveDeliveries() async { + if (_isPosting) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Already posting, skipping...'); + return; + } + _isPosting = true; + + try { + // Get userid from SharedPreferences + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Checking for user: $userId'); + + if (userId == 0) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] ❌ No user ID found in prefs'); + return; + } + + // Get current date dynamically (YYYY-MM-DD format) + final now = DateTime.now(); + final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}'; + + // Build API URL using v1 endpoint (getdeliveries) + final bool isLive = ApiConstants.mainRoute == 'live'; + final baseUrl = isLive + ? ApiConstants.currentDeliveryLive + : ApiConstants.currentDeliveryDev; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Fetching deliveries: $uri'); + + // Fetch deliveries from API using v1 endpoint + final deliveries = await _fetchDeliveriesFromApi(uri); + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] API returned ${deliveries.length} items', + ); + + // Filter for active orders only + final activeOrders = deliveries.whereType>().where(( + order, + ) { + final status = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isActive = status == 'active'; + return isActive; + }).toList(); + + if (activeOrders.isEmpty) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] No active orders found'); + // ✅ Still check shift end even if no active deliveries + debugPrint('[ACTIVE_DELIVERY_LOG][BG] 🔍 Checking shift end time...'); + await checkShiftEnd(); + return; + } + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Found ${activeOrders.length} active deliveries', + ); + + // Post logs for each active delivery + for (final order in activeOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + // Get current coordinates once per order to reuse for proximity + log + final Map? coords = await _getCoordinatesWithFallback(); + if (coords != null) { + await _checkProximityAlert(order, coords); + } + + // Get payload from SharedPreferences or create new one + Map? payload = await _loadPayload(orderId); + if (payload == null) { + // Create new payload from order data with userId from SharedPreferences + payload = _createPayload(order, userId); + await _persistPayload(orderId, payload); + await _markAsActive(orderId); + } else { + // Ensure userid is set correctly in existing payload + payload['userid'] = userId; + } + + // Post the log + await _postDeliveryLog(orderId, payload, currentCoords: coords); + } + + // ✅ CRITICAL: Check if shift end time has passed (backup to alarm) + await checkShiftEnd(); + + // Attempt to flush offline logs + await _flushOfflineLogs(); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error processing: $e'); + } finally { + _isPosting = false; + } + } + + /// Post delivery log for a specific order + static Future _postDeliveryLog( + String orderId, + Map payload, { + Map? currentCoords, + }) async { + Map? payloadWithCoords; + try { + // Get current coordinates + final coords = currentCoords ?? await _getCoordinatesWithFallback(); + if (coords == null) { + return; + } + + // Distance calculation is now fully handled by LiveTrackingService in the background. + // 03-14: Removed redundant 60-second background calculations to avoid logic conflicts. + final deliveryId = payload['deliveryid']?.toString() ?? ''; + + // Create log date timestamp + final now = DateTime.now(); + final logDate = + '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + + // Retrieve final cumulative KM to send + double riderKmsToSend = 0.0; + if (deliveryId.isNotEmpty && deliveryId != '0') { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs + .reload(); // Force reload so we get the latest value written by the main isolate + riderKmsToSend = + double.tryParse( + prefs.getString( + 'delivery_tracking_${deliveryId}_cumulativeKm', + ) ?? + '0', + ) ?? + 0.0; + } catch (_) {} + } + + // Build payload with coordinates and timestamp + payloadWithCoords = { + ...payload, + 'logdate': logDate, + 'latitude': coords['lat'] ?? '0', + 'longitude': coords['lng'] ?? '0', + 'raw_latitude': coords['raw_lat'] ?? '0', + 'raw_longitude': coords['raw_lng'] ?? '0', + 'velocity_lat': coords['velocity_lat'] ?? '0', + 'velocity_lng': coords['velocity_lng'] ?? '0', + 'speed': coords['speed'] ?? '0', + 'heading': coords['heading'] ?? '0', + 'riderkms': riderKmsToSend, + 'logstatus': await _resolveLogStatus(orderId, coords), + }; + + // Determine API endpoint + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + // Post the log + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId'); + + final result = await _logProvider + .createDeliveryLog(url, payloadWithCoords) + .timeout( + const Duration(seconds: 8), + onTimeout: () => throw TimeoutException( + 'Delivery log post timeout for $orderId', + ), + ); + + if (result != null) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Success for $orderId'); + await _persistLastLogLocation(orderId, coords); + } else { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Warning: No response for $orderId', + ); + } + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Failed to post log for $orderId: $e', + ); + // Offline fallback + if (payloadWithCoords != null) { + await _saveToOfflineQueue(orderId, payloadWithCoords); + } + } + } + + /// Create payload from delivery order data + static Map _createPayload( + Map delivery, + int userId, + ) { + return { + 'logid': 0, + 'tenantid': delivery['tenantid'] ?? 0, + 'partnerid': delivery['partnerid'] ?? 0, + 'locationid': delivery['locationid'] ?? 0, + 'orderheaderid': delivery['orderheaderid'] ?? 0, + 'deliveryid': delivery['deliveryid'] ?? 0, + 'userid': userId, + 'orderid': (delivery['orderid'] ?? '').toString(), + 'orderstatus': 'active', + }; + } + + /// Get coordinates with fallback (current position -> last known -> cached) + static Future?> _getCoordinatesWithFallback() async { + Map result = { + 'lat': '0', + 'lng': '0', + 'raw_lat': '0', + 'raw_lng': '0', + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + + try { + // 1. Check if location services are enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location services disabled'); + return await _getCachedCoordinatesAsMap(); + } + + // 2. Check permissions + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location permission denied'); + return await _getCachedCoordinatesAsMap(); + } + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Failed to get current pos: $e'); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null) { + // Reject mocked GPS (anti-cheat) + if (position.isMocked) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Mocked position — using cached', + ); + return await _getCachedCoordinatesAsMap(); + } + // Reject very poor accuracy to avoid phantom movements in delivery logs + if (position.accuracy > 50.0) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Low-accuracy position (${position.accuracy.toStringAsFixed(0)}m) — using cached', + ); + return await _getCachedCoordinatesAsMap(); + } + + final now = DateTime.now(); + double outLat = position.latitude; + double outLng = position.longitude; + double speed = position.speed; + double heading = position.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (math.pi / 180.0); + final double velocityLng = speed * math.sin(headingRadians); + final double velocityLat = speed * math.cos(headingRadians); + + if (_kf == null) { + _kf = DoormileKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + result = { + 'lat': outLat.toString(), + 'lng': outLng.toString(), + 'raw_lat': position.latitude.toString(), + 'raw_lng': position.longitude.toString(), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + }; + + await _cacheCoordinates(result['lat']!, result['lng']!); + return result; + } + + return await _getCachedCoordinatesAsMap(); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error getting coords: $e'); + return await _getCachedCoordinatesAsMap(); + } + } + + static Future?> _getCachedCoordinatesAsMap() async { + final coords = await _getCachedCoordinates(); + if (coords == null) return null; + return { + 'lat': coords.$1, + 'lng': coords.$2, + 'raw_lat': coords.$1, + 'raw_lng': coords.$2, + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + } + + /// Cache coordinates to SharedPreferences + static Future _cacheCoordinates(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + } + + /// Get cached coordinates from SharedPreferences + static Future<(String, String)?> _getCachedCoordinates() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty) { + return (lat, lng); + } + } catch (_) {} + return null; + } + + /// Load payload from SharedPreferences + static Future?> _loadPayload(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString('$_payloadKeyPrefix$orderId'); + if (jsonStr == null || jsonStr.isEmpty) return null; + final decoded = jsonDecode(jsonStr); + if (decoded is Map) return decoded; + if (decoded is Map) return decoded.cast(); + } catch (_) {} + return null; + } + + /// Persist payload to SharedPreferences + static Future _persistPayload( + String orderId, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_payloadKeyPrefix$orderId', jsonEncode(payload)); + } catch (_) {} + } + + /// Mark order as active in SharedPreferences + static Future _markAsActive(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(_activeDeliveriesKey) ?? []; + if (!list.contains(orderId)) { + list.add(orderId); + await prefs.setStringList(_activeDeliveriesKey, list); + } + } catch (_) {} + } + + /// Fetch deliveries from API using v1 endpoint + static Future> _fetchDeliveriesFromApi(Uri uri) async { + try { + final res = await _httpClient + .get(uri) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode >= 200 && res.statusCode < 300) { + final decoded = json.decode(res.body); + + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + if (data is List) { + return data; + } + if (data is Map && data['items'] is List) { + return data['items'] as List; + } + + return []; + } + return []; + } catch (e) { + return []; + } + } + + /// Helper to pad numbers with leading zero + static String _pad(int n) => n.toString().padLeft(2, '0'); + + static Future _resolveLogStatus( + String orderId, + Map coords, + ) async { + try { + final last = await _loadLastLogLocation(orderId); + if (last != null) { + final lastLat = last.$1; + final lastLng = last.$2; + final currentLat = double.tryParse(coords['lat'] ?? '0') ?? 0; + final currentLng = double.tryParse(coords['lng'] ?? '0') ?? 0; + if (currentLat != 0 && + currentLng != 0 && + lastLat != 0 && + lastLng != 0) { + final distance = Geolocator.distanceBetween( + lastLat, + lastLng, + currentLat, + currentLng, + ); + if (distance <= _idleThresholdMeters) { + return 1; // idle + } + } + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] logstatus error: $e'); + } + return 0; // moving + } + + static Future<(double, double)?> _loadLastLogLocation(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString('last_log_loc_$orderId'); + if (jsonStr == null || jsonStr.isEmpty) return null; + final decoded = jsonDecode(jsonStr); + if (decoded is Map) { + final lat = double.tryParse('${decoded['lat']}') ?? 0; + final lng = double.tryParse('${decoded['lng']}') ?? 0; + if (lat != 0 && lng != 0) return (lat, lng); + } + } catch (_) {} + return null; + } + + static Future _persistLastLogLocation( + String orderId, + Map coords, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_log_loc_$orderId', + jsonEncode({'lat': coords['lat'], 'lng': coords['lng']}), + ); + } catch (_) {} + } + + // ---------------- Offline Queue Logic ---------------- + + static Future _flushOfflineLogs() async { + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + if (queue.isEmpty) return; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + for (final itemStr in queue) { + try { + final Map item = jsonDecode(itemStr); + final String orderId = item['orderId'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (payload.isEmpty) continue; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId', + ); + + final result = await _logProvider + .createDeliveryLog(url, payload) + .timeout(const Duration(seconds: 8)); + + if (result != null) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Success for orderId: $orderId', + ); + anySuccess = true; + } else { + remaining.add(itemStr); + } + } catch (e) { + remaining.add(itemStr); + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flush error: $e'); + } + } + + static Future _saveToOfflineQueue( + String orderId, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + + final item = jsonEncode({ + 'orderId': orderId, + 'payload': payload, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + + queue.add(item); + await prefs.setStringList(_offlineLogKey, queue); + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}', + ); + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Error saving to queue: $e', + ); + } + } + + // ---------------- Auto Shift End Logic ---------------- + + /// Check if shift has ended and trigger auto-break if needed + static Future checkShiftEnd() async { + try { + debugPrint('[AUTO_SHIFT_END] 🔍 Starting shift end check...'); + final prefs = await SharedPreferences.getInstance(); + + // 1. Check if currently On Duty + final int onduty = prefs.getInt('onduty') ?? 0; + debugPrint('[AUTO_SHIFT_END] 📊 Current onduty status: $onduty'); + if (onduty != 1) { + debugPrint('[AUTO_SHIFT_END] ⏭️ Rider not on duty, skipping check'); + return; // Already offline + } + + // 2. Get Shift Timings + final String startTimeStr = prefs.getString('starttime') ?? ''; + final String endTimeStr = prefs.getString('endtime') ?? ''; + debugPrint( + '[AUTO_SHIFT_END] ⏰ Shift times - Start: "$startTimeStr", End: "$endTimeStr"', + ); + + if (endTimeStr.isEmpty) { + debugPrint( + '[AUTO_SHIFT_END] ⚠️ No endtime found, cannot check shift end', + ); + return; + } + + // 3. Parse Times (Assumed format HH:mm:ss) + final now = DateTime.now(); + + final endParts = endTimeStr.split(':'); + if (endParts.length < 2) return; + + final int endHour = int.tryParse(endParts[0]) ?? 0; + final int endMinute = int.tryParse(endParts[1]) ?? 0; + final int endSecond = endParts.length > 2 + ? (int.tryParse(endParts[2]) ?? 0) + : 0; + + // Create DateTime for end time on TODAY + final DateTime endToday = DateTime( + now.year, + now.month, + now.day, + endHour, + endMinute, + endSecond, + ); + + bool isShiftOver = false; + + if (startTimeStr.isNotEmpty) { + final startParts = startTimeStr.split(':'); + if (startParts.length >= 2) { + final int startHour = int.tryParse(startParts[0]) ?? 0; + final int startMinute = int.tryParse(startParts[1]) ?? 0; + + // Create DateTime for start time on TODAY + final DateTime startToday = DateTime( + now.year, + now.month, + now.day, + startHour, + startMinute, + ); + + // Check for overnight shift (Start > End in 24-hour format, e.g. 22:00 to 06:00) + // This means shift crosses midnight + final double startVal = startHour + (startMinute / 60.0); + final double endVal = endHour + (endMinute / 60.0); + + if (startVal > endVal) { + // ✅ OVERNIGHT SHIFT (crosses midnight, e.g. 22:00 to 06:00) + // Shift is over if current time is AFTER end time today AND BEFORE start time today + // Example: End 06:00, Start 22:00 + // - Now 23:00 -> After 22:00 (start) -> Still in shift (ACTIVE) + // - Now 05:00 -> Before 06:00 (end) -> Still in shift from yesterday (ACTIVE) + // - Now 10:00 -> After 06:00 (end) AND Before 22:00 (start) -> Shift OVER + + debugPrint( + '[AUTO_SHIFT_END] 🌙 Overnight shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})', + ); + debugPrint( + '[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}', + ); + + if (now.isAfter(endToday) && now.isBefore(startToday)) { + // We're in the gap period between end and start -> shift is OVER + isShiftOver = true; + debugPrint( + '[AUTO_SHIFT_END] ✅ Overnight shift: In gap period -> SHIFT OVER', + ); + } else { + // We're either before end (still in shift from yesterday) or after start (still in shift today) + isShiftOver = false; + debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: Still active'); + } + } else { + // ✅ NORMAL DAY SHIFT (e.g. 09:00 to 17:00, doesn't cross midnight) + // Shift is over if current time is AFTER end time + debugPrint( + '[AUTO_SHIFT_END] ☀️ Day shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})', + ); + debugPrint( + '[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}, End time: ${_pad(endHour)}:${_pad(endMinute)}', + ); + + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Shift ended'); + } else { + isShiftOver = false; + debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Still active'); + } + } + } else { + // Fallback if start time parse fails: assume day shift + debugPrint( + '[AUTO_SHIFT_END] ⚠️ Could not parse start time, assuming day shift', + ); + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + } + } + } else { + // Fallback if no start time: assume day shift + debugPrint( + '[AUTO_SHIFT_END] ⚠️ No start time provided, assuming day shift', + ); + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + } + } + + // 4. Trigger Auto End if Shift is Over + debugPrint( + '[AUTO_SHIFT_END] 📅 Time check result - isShiftOver: $isShiftOver, Current: ${_pad(now.hour)}:${_pad(now.minute)}, End: ${_pad(endHour)}:${_pad(endMinute)}', + ); + if (isShiftOver) { + debugPrint( + '[AUTO_SHIFT_END] ⏰ Shift ended (Start: $startTimeStr, End: $endTimeStr). Current: ${_pad(now.hour)}:${_pad(now.minute)}', + ); + await _autoEndShift(prefs); + } else { + debugPrint('[AUTO_SHIFT_END] ✅ Shift not ended yet, continuing...'); + } + } catch (e) { + debugPrint('[AUTO_SHIFT_END] ❌ Error checking shift end: $e'); + debugPrint('[AUTO_SHIFT_END] Stack trace: ${StackTrace.current}'); + } + } + + static Future _autoEndShift(SharedPreferences prefs) async { + try { + debugPrint( + '[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...', + ); + + // 1. Get Required IDs + final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + final int partnerid = + prefs.getInt('partnerid') ?? prefs.getInt('partnerId') ?? 0; + final int shiftid = + prefs.getInt('shiftid') ?? prefs.getInt('shiftId') ?? 0; + final int logid = prefs.getInt('logid') ?? prefs.getInt('logId') ?? 0; + + if (userid == 0) { + debugPrint( + '[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log', + ); + return; + } + + // 2. Get Location + final Map? coords = await _getCoordinatesWithFallback(); + final String lat = coords?['lat'] ?? '0'; + final String lng = coords?['lng'] ?? '0'; + + // 3. Prepare Break Log Payload + final now = DateTime.now(); + final int localBreakId = + (DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID + final String breakdate = + '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + final String breakstart = + '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + + final payload = { + "breakid": localBreakId, + "logid": logid, + "breakdate": breakdate, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "breakstart": breakstart, + "breakend": "", + "breakhours": 0.0, + "latitude": lat, + "longitude": lng, + }; + + // 4. Call API to Create Break + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createBreakRiderLogLive + : ApiConstants.createBreakRiderLogDev; + + debugPrint('[AUTO_SHIFT_END] Creating break log: $url'); + + // We use a separate provider instance or http call if needed, + // but _logProvider is for delivery logs. We need a generic post or use http directly. + // Since we don't have BreakRiderLogProvider here, we'll use http directly for simplicity and isolation. + + try { + final response = await _httpClient + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode >= 200 && response.statusCode < 300) { + debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully'); + + // Parse response to get server break ID if needed, but mainly we just need to go offline + final body = jsonDecode(response.body); + final det = (body['details'] is Map) ? body['details'] : body; + final serverBreakId = det['breakid']; + + if (serverBreakId != null) { + await prefs.setInt( + 'breakId', + int.tryParse('$serverBreakId') ?? localBreakId, + ); + } + await prefs.setString('breakStart', breakstart); + await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); + } else { + debugPrint( + '[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}', + ); + } + } catch (e) { + debugPrint('[AUTO_SHIFT_END] ❌ API Error: $e'); + // Even if API fails, we MUST go offline locally to prevent further issues + } + + // 5. Set Offline Locally + await prefs.setInt('onduty', 0); + await prefs.setBool('online', false); + + // 6. Update Rider Log (Set Duty = 0) + // We should also update the main rider log to say onduty=0 + final updateUrl = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateRiderLogLive + : ApiConstants.updateRiderLogDev; + + final updatePayload = { + "userid": userid, + "onduty": 0, + "latitude": lat, + "longitude": lng, + }; + + try { + await _httpClient + .post( + Uri.parse(updateUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(updatePayload), + ) + .timeout(const Duration(seconds: 5)); + debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline'); + } catch (_) {} + + debugPrint('[AUTO_SHIFT_END] 🏁 Auto-shift end sequence complete.'); + } catch (e) { + debugPrint('[AUTO_SHIFT_END] Critical error in _autoEndShift: $e'); + } + } + + /// Trigger proximity alert once per delivery when within threshold + static Future _checkProximityAlert( + Map order, + Map coords, + ) async { + try { + final deliveryId = (order['deliveryid'] ?? order['orderid'] ?? '') + .toString(); + if (deliveryId.isEmpty) return; + + final dropLatRaw = + (order['droplat'] ?? order['DropLat'] ?? order['deliverylat'] ?? '') + .toString(); + final dropLngRaw = + (order['droplon'] ?? + order['droplong'] ?? + order['DropLon'] ?? + order['DropLong'] ?? + order['deliverylong'] ?? + '') + .toString(); + final dropLat = double.tryParse(dropLatRaw) ?? 0.0; + final dropLng = double.tryParse(dropLngRaw) ?? 0.0; + if (dropLat == 0 || + dropLng == 0 || + dropLat.abs() > 90 || + dropLng.abs() > 180) { + return; + } + + final riderLat = double.tryParse(coords['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(coords['lng'] ?? '0') ?? 0.0; + if (riderLat == 0 || + riderLng == 0 || + riderLat.abs() > 90 || + riderLng.abs() > 180) { + return; + } + + final distanceMeters = Geolocator.distanceBetween( + dropLat, + dropLng, + riderLat, + riderLng, + ); + + if (distanceMeters <= _proximityThresholdMeters) { + final prefs = await SharedPreferences.getInstance(); + final alreadyAlerted = + prefs.getBool('$_proximityAlertKeyPrefix$deliveryId') ?? false; + if (alreadyAlerted) { + debugPrint( + '[PROXIMITY] Already alerted for deliveryId=$deliveryId, skipping', + ); + return; + } + + await NotificationServce.showLocalNotification( + title: 'Near delivery location', + body: 'You\'ve reached your destination. Please update the status.', + ); + + final played = await _playProximityAudio(); + await prefs.setBool('$_proximityAlertKeyPrefix$deliveryId', true); + + debugPrint( + '[PROXIMITY] Alerted deliveryId=$deliveryId at ${distanceMeters.toStringAsFixed(1)}m ' + 'target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played', + ); + } else { + debugPrint( + '[PROXIMITY] Skipped alert for deliveryId=$deliveryId | distance=${distanceMeters.toStringAsFixed(1)}m', + ); + } + } catch (e) { + debugPrint('[PROXIMITY] Error sending alert: $e'); + } + } + + static Future _playProximityAudio() async { + try { + if (!_audioReady) { + await _proximityPlayer.setReleaseMode(ReleaseMode.stop); + await _proximityPlayer.setVolume(1.0); + _audioReady = true; + } + // Attempt to play bundled destination audio + await _proximityPlayer.stop(); + await _proximityPlayer.play(AssetSource('audio/destination.mp3')); + debugPrint('[PROXIMITY][AUDIO] Playing destination.mp3'); + return true; + } catch (e) { + debugPrint('[PROXIMITY][AUDIO] Error playing destination clip: $e'); + } + + // Fallback to TTS if audio fails + try { + // Initialize once + if (!_ttsReady) { + await _tts.setLanguage('en-US'); + await _tts.setSpeechRate(0.9); + await _tts.setVolume(1.0); + await _tts.setPitch(1.0); + _ttsReady = true; + } + // Speak without awaiting completion to avoid blocking + await _tts.speak( + 'You have reached your destination. Please update the delivery status.', + ); + debugPrint('[PROXIMITY][TTS] Spoke destination prompt'); + return true; + } catch (e) { + debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e'); + } + return false; + } +} diff --git a/lib/xpress/background/foreground_service.dart b/lib/xpress/background/foreground_service.dart new file mode 100644 index 0000000..fa0ae9a --- /dev/null +++ b/lib/xpress/background/foreground_service.dart @@ -0,0 +1,500 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'dart:math' as math; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart'; +import 'package:miler/xpress/background/backgroundservice.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:miler/xpress/utils/kalman_filter.dart'; +import 'package:miler/xpress/utils/mqtt_service.dart'; +import 'package:miler/xpress/views/helpers/constants/mqtt_constants.dart'; +import 'package:battery_plus/battery_plus.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'dart:io'; +import 'package:miler/helpers/http_overrides.dart'; + +class _BackgroundRiderLog { + static DoormileKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static Future> _ensureLatLng() async { + Map result = { + 'lat': '0', + 'lng': '0', + 'raw_lat': '0', + 'raw_lng': '0', + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + 'status': 'unknown', + 'accuracy': '0', + }; + try { + // 1. Check if location services are enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[BG_RIDER_LOG] Location services are disabled.'); + result['status'] = 'disabled'; + return result; + } + + // 2. Check permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + debugPrint('[BG_RIDER_LOG] Location permission denied.'); + result['status'] = 'denied'; + return result; + } + if (permission == LocationPermission.deniedForever) { + debugPrint('[BG_RIDER_LOG] Location permission denied forever.'); + result['status'] = 'denied_forever'; + return result; + } + + result['status'] = 'enabled'; + + // 3. Get position (using non-deprecated LocationSettings + explicit timeout) + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + // Reject mocked positions (anti-cheat) + if (pos.isMocked) { + debugPrint('[BG_RIDER_LOG] Mocked position detected — using cached'); + return result; + } + + final now = DateTime.now(); + double outLat = pos.latitude; + double outLng = pos.longitude; + double speed = pos.speed; + double heading = pos.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (math.pi / 180.0); + final double velocityLng = speed * math.sin(headingRadians); + final double velocityLat = speed * math.cos(headingRadians); + + if (_kf == null) { + _kf = DoormileKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; // Default background interval + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + return { + 'lat': outLat.toStringAsFixed(6), + 'lng': outLng.toStringAsFixed(6), + 'raw_lat': pos.latitude.toStringAsFixed(6), + 'raw_lng': pos.longitude.toStringAsFixed(6), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + 'status': 'enabled', + 'accuracy': pos.accuracy.toStringAsFixed(1), + }; + } catch (e) { + debugPrint('[BG_RIDER_LOG] Error getting location: $e'); + return result; + } + } + + static String _two(int n) => n.toString().padLeft(2, '0'); + static String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + static String _formatTime(DateTime dt) { + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$hh:$mm:$ss"; + } + + /// Accumulates cumulative KMs for active deliveries using the foreground service GPS position. + /// Only runs when LiveTrackingService (main isolate) hasn't updated in the last 10 seconds, + /// which means the app is backgrounded/screen-off/power-saver and the main isolate is dormant. + static Future _accumulateBackgroundKms( + SharedPreferences prefs, + Map loc, + ) async { + try { + // Check if the main isolate's LiveTrackingService is still actively updating + final lastLiveUpdateMs = + prefs.getInt('live_tracking_last_update_ms') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final secondsSinceLiveUpdate = (nowMs - lastLiveUpdateMs) / 1000.0; + + if (secondsSinceLiveUpdate < 10.0) { + // Main isolate is active — let it handle KMs to avoid race conditions + debugPrint( + '[BG_KM] LiveTrackingService active (${secondsSinceLiveUpdate.toStringAsFixed(1)}s ago) — skipping background accumulation', + ); + return; + } + + // Skip if GPS accuracy is too poor for reliable KM tracking + final double accuracy = + double.tryParse(loc['accuracy'] ?? '9999') ?? 9999.0; + if (accuracy > 50.0) { + debugPrint( + '[BG_KM] Low-accuracy position (${accuracy.toStringAsFixed(0)}m) — skipping KM accumulation', + ); + return; + } + + final double currentLat = double.tryParse(loc['lat'] ?? '0') ?? 0.0; + final double currentLng = double.tryParse(loc['lng'] ?? '0') ?? 0.0; + if (currentLat == 0.0 || currentLng == 0.0) return; + + final activeDeliveryIds = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + + for (final dId in activeDeliveryIds) { + try { + final lastLatStr = + prefs.getString('delivery_tracking_${dId}_lastLat') ?? ''; + final lastLngStr = + prefs.getString('delivery_tracking_${dId}_lastLng') ?? ''; + final currentCumKm = + double.tryParse( + prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0', + ) ?? + 0.0; + + if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) { + final lastLat = double.tryParse(lastLatStr) ?? 0.0; + final lastLng = double.tryParse(lastLngStr) ?? 0.0; + + if (lastLat != 0.0 && lastLng != 0.0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + currentLat, + currentLng, + ); + + // Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s). + // Uses elapsed time since last recorded position so the threshold scales + // correctly whether the background interval is 30s, 60s, or longer. + final lastUpdateMs = + prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final elapsedSeconds = lastUpdateMs > 0 + ? (nowMs - lastUpdateMs) / 1000.0 + : 60.0; // conservative default + final maxRealisticMeters = + elapsedSeconds * 33.3; // 120 km/h ceiling + + if (distanceMeters > maxRealisticMeters && + distanceMeters > 50.0) { + // GPS jumped — update anchor without counting phantom distance + debugPrint( + '[BG_KM] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (max: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor', + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + currentLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + currentLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + nowMs, + ); + } else if (distanceMeters >= 5.0) { + final newCumKm = currentCumKm + (distanceMeters / 1000.0); + await prefs.setString( + 'delivery_tracking_${dId}_cumulativeKm', + newCumKm.toStringAsFixed(4), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + currentLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + currentLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + nowMs, + ); + debugPrint( + '[BG_KM] +${(distanceMeters / 1000.0).toStringAsFixed(4)} km for $dId ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (total: ${newCumKm.toStringAsFixed(4)} km)', + ); + } + } + } else { + // No anchor yet — set initial position + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + currentLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + currentLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + DateTime.now().millisecondsSinceEpoch, + ); + } + } catch (_) {} + } + } catch (e) { + debugPrint('[BG_KM] Error in background KM accumulation: $e'); + } + } + + static Future createLoginNow() async { + try { + final prefs = await SharedPreferences.getInstance(); + // Reload from disk so we see the latest values written by the main isolate + await prefs.reload(); + + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + return; + } + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = + prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + if ((userid ?? 0) == 0) return; + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + // ✅ Check if there are active deliveries to set status + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + final loc = await _ensureLatLng(); + + // Accumulate KMs in background when LiveTrackingService (main isolate) is not active + await _accumulateBackgroundKms(prefs, loc); + + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + final String orderId = prefs.getString('current_riding_order_id') ?? ''; + + final payload = { + "logid": logid ?? 0, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "logdate": iso, + "login": loginTime, + "latitude": loc['lat'] ?? '0', + "longitude": loc['lng'] ?? '0', + "raw_latitude": loc['raw_lat'] ?? '0', + "raw_longitude": loc['raw_lng'] ?? '0', + "velocity_lat": loc['velocity_lat'] ?? '0', + "velocity_lng": loc['velocity_lng'] ?? '0', + "speed": loc['speed'] ?? '0', + "heading": loc['heading'] ?? '0', + "onduty": 1, + "status": riderStatus, + "contactno": prefs.getString('contactno') ?? '', + "tenantid": tenantid ?? 0, + "locationid": locationid ?? 0, + "applocationid": applocationid ?? 0, + "userfcmtoken": userfcmtoken ?? '', + "username": (username ?? '').trim(), + "orderid": orderId, + }; + + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + final provider = CreateRiderLogProvider(); + final resp = await provider.createRiderLog(base, payload); + + if (resp == null || resp.isEmpty) return; + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + final newLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + await prefs.setInt('logid', newLogId); + await prefs.setInt('logId', newLogId); + + // ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) + final mqttService = DoormileMqttService(); + if (!mqttService.isConnected) { + // Use a slightly different client ID for background to avoid kicking the main one off + await mqttService.connect(); + } + + if (mqttService.isConnected) { + // Gather Telemetry + final battery = Battery(); + final int batteryLevel = await battery.batteryLevel; + final BatteryState batteryState = await battery.batteryState; + final isCharging = + batteryState == BatteryState.charging || + batteryState == BatteryState.full; + + final connectivity = await Connectivity().checkConnectivity(); + final String connType = connectivity.isNotEmpty + ? connectivity.first.toString().split('.').last + : 'none'; + + // 1. Direct Telemetry (Feeding the /full API) + mqttService.publish('battery', '$batteryLevel%'); + mqttService.publish('charging', isCharging ? 'yes' : 'no'); + mqttService.publish('speed', loc['speed'] ?? '0'); + mqttService.publish('connection', connType); + mqttService.publish('accuracy', loc['accuracy'] ?? '0'); + + // 2. Alert if Location is Off + final String locStatus = loc['status'] ?? 'unknown'; + if (locStatus != 'enabled') { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'location_turned_off', + 'error_type': locStatus, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'logdate': iso, + }); + } + + // 3. Low Battery Alert + if (batteryLevel < 15 && !isCharging) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'low_battery_warning', + 'battery': '$batteryLevel%', + 'logdate': iso, + }); + } + + // 4. Poor GPS Accuracy Alert + final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0; + if (accuracy > 30) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'poor_gps_signal', + 'accuracy': '${accuracy.toStringAsFixed(1)}m', + 'logdate': iso, + }); + } + + // 5. Lane: Status + mqttService.updateStatus( + riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, + ); + + // 6. Lane: Periodic Log (Comprehensive Snapshot) + mqttService.publishLog('rider_periodic_log', { + 'userid': userid, + 'username': username, + 'logdate': iso, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'accuracy': loc['accuracy'] ?? '0', + 'status': riderStatus, + 'orderid': orderId, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'location_service': locStatus, + 'is_background': true, + }); + } + } catch (e) { + // ignore background errors + } + } +} + +class RiderLogTaskHandler extends TaskHandler { + Timer? _timer; // not used; plugin provides repeat callback, but keep safety + + @override + Future onStart(DateTime timestamp, SendPort? sendPort) async { + // No-op + } + + @override + Future onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { + // 1. Rider Log (existing) + await _BackgroundRiderLog.createLoginNow(); + + // 2. Delivery Log (new) + await BackgroundDeliveryLog.processActiveDeliveries(); + + // 3. Auto Shift End (new) + await BackgroundDeliveryLog.checkShiftEnd(); + } + + @override + Future onDestroy(DateTime timestamp, SendPort? sendPort) async { + _timer?.cancel(); + _timer = null; + } +} + +@pragma('vm:entry-point') +void riderLogCallback() { + HttpOverrides.global = MyHttpOverrides(); + FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler()); +} diff --git a/lib/xpress/background/live_tracking_service.dart b/lib/xpress/background/live_tracking_service.dart new file mode 100644 index 0000000..95fc609 --- /dev/null +++ b/lib/xpress/background/live_tracking_service.dart @@ -0,0 +1,330 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; +import 'dart:math' as math; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/utils/kalman_filter.dart'; +import 'package:miler/xpress/utils/mqtt_service.dart'; +import 'package:battery_plus/battery_plus.dart'; + +class LiveTrackingService { + static final LiveTrackingService _instance = LiveTrackingService._internal(); + + factory LiveTrackingService() => _instance; + + LiveTrackingService._internal(); + + StreamSubscription? _positionStreamSubscription; + bool _isTracking = false; + bool _isProcessing = false; + DoormileKalmanFilter? _kf; + DateTime? _lastUpdateTime; + DateTime? _lastSentTime; + DateTime? _lastTelemetrySent; + Timer? _watchdogTimer; + + // Thresholds + static const double _maxAccuracyMeters = 50.0; // Reject GPS > 50m accuracy + static const double _minDistanceMeters = 5.0; // Minimum movement to count + static const double _maxJumpMeters = 200.0; // Reject single-step jumps > 200m + static const int _rateLimitSeconds = 3; // Minimum seconds between updates + static const int _watchdogSeconds = 45; // Restart if no update in 45s + + void startTracking() async { + if (_isTracking) return; + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[LIVE TRACKING] Location services disabled.'); + return; + } + + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint('[LIVE TRACKING] Location permission denied.'); + return; + } + + _isTracking = true; + _lastSentTime = null; + debugPrint('[LIVE TRACKING] Starting high-frequency tracking...'); + + _startStream(); + _startWatchdog(); + } + + void _startStream() { + _positionStreamSubscription?.cancel(); + + const locationSettings = LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: 0, + ); + + _positionStreamSubscription = + Geolocator.getPositionStream(locationSettings: locationSettings).listen( + (Position position) async { + await _sendToKalmanBackend(position); + }, + onError: (error) { + debugPrint( + '[LIVE TRACKING] Stream error: $error — restarting in 5s', + ); + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + if (_isTracking) { + Future.delayed(const Duration(seconds: 5), () { + if (_isTracking) _startStream(); + }); + } + }, + cancelOnError: true, + ); + } + + void _startWatchdog() { + _watchdogTimer?.cancel(); + _watchdogTimer = Timer.periodic(const Duration(seconds: _watchdogSeconds), ( + _, + ) { + if (!_isTracking) return; + final lastSent = _lastSentTime; + if (lastSent == null) return; + final staleSeconds = DateTime.now().difference(lastSent).inSeconds; + if (staleSeconds > _watchdogSeconds) { + debugPrint( + '[LIVE TRACKING] Watchdog: stream stale for ${staleSeconds}s — restarting', + ); + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + _kf = null; + _lastUpdateTime = null; + _startStream(); + } + }); + } + + void stopTracking() { + if (!_isTracking) return; + debugPrint('[LIVE TRACKING] Stopping tracking.'); + _watchdogTimer?.cancel(); + _watchdogTimer = null; + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + _kf = null; + _lastUpdateTime = null; + _isTracking = false; + } + + Future _sendToKalmanBackend(Position position) async { + final now = DateTime.now(); + + // Rate limiter + if (_lastSentTime != null && + now.difference(_lastSentTime!).inSeconds < _rateLimitSeconds) { + return; + } + + // Mutex — prevent concurrent processing + if (_isProcessing) return; + _isProcessing = true; + _lastSentTime = now; + + try { + // Reject mocked GPS (anti-cheat) + if (position.isMocked) { + debugPrint('[LIVE TRACKING] Skipped mocked position'); + return; + } + + // Reject poor-accuracy GPS (power saver / other apps degrading GPS) + if (position.accuracy > _maxAccuracyMeters) { + debugPrint( + '[LIVE TRACKING] Skipped low-accuracy position: ${position.accuracy.toStringAsFixed(0)}m', + ); + return; + } + + final prefs = await SharedPreferences.getInstance(); + final userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final currentOrderId = prefs.getString('current_riding_order_id') ?? ''; + + final double headingRadians = position.heading * (math.pi / 180.0); + final double velocityLng = position.speed * math.sin(headingRadians); + final double velocityLat = position.speed * math.cos(headingRadians); + + double displayLat = position.latitude; + double displayLng = position.longitude; + + if (_kf == null) { + _kf = DoormileKalmanFilter( + lat: position.latitude, + lng: position.longitude, + ); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : _rateLimitSeconds.toDouble(); + _kf!.predict(dt); + _kf!.update(position.latitude, position.longitude); + displayLat = _kf!.x[0]; + displayLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + // Stamp that the main isolate is actively tracking. + // The foreground service reads this to avoid double-counting KMs. + await prefs.setInt( + 'live_tracking_last_update_ms', + now.millisecondsSinceEpoch, + ); + + final payload = { + 'userid': userid, + 'orderid': currentOrderId, + 'lat': displayLat, + 'lng': displayLng, + 'raw_lat': position.latitude, + 'raw_lng': position.longitude, + 'speed': position.speed, + 'heading': position.heading, + 'velocity_lat': velocityLat, + 'velocity_lng': velocityLng, + 'timestamp': now.toIso8601String(), + }; + + // Accumulate cumulative KMs for active deliveries + final activeDeliveryIds = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + for (final dId in activeDeliveryIds) { + try { + final lastLatStr = + prefs.getString('delivery_tracking_${dId}_lastLat') ?? ''; + final lastLngStr = + prefs.getString('delivery_tracking_${dId}_lastLng') ?? ''; + final currentCumKm = + double.tryParse( + prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0', + ) ?? + 0.0; + + if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) { + final lastLat = double.tryParse(lastLatStr) ?? 0.0; + final lastLng = double.tryParse(lastLngStr) ?? 0.0; + + if (lastLat != 0 && + lastLng != 0 && + displayLat != 0 && + displayLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + displayLat, + displayLng, + ); + + // Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s) + final lastUpdateMs = + prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0; + final elapsedSeconds = lastUpdateMs > 0 + ? (now.millisecondsSinceEpoch - lastUpdateMs) / 1000.0 + : _rateLimitSeconds.toDouble(); + final maxRealisticMeters = + elapsedSeconds * 33.3; // 120 km/h ceiling + + if (distanceMeters > maxRealisticMeters && + distanceMeters > _maxJumpMeters) { + // GPS jumped — update anchor without counting the phantom distance + debugPrint( + '[LIVE TRACKING] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (max realistic: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor', + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + displayLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + displayLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + now.millisecondsSinceEpoch, + ); + } else if (distanceMeters >= _minDistanceMeters) { + final newCumKm = currentCumKm + (distanceMeters / 1000.0); + await prefs.setString( + 'delivery_tracking_${dId}_cumulativeKm', + newCumKm.toStringAsFixed(4), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + displayLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + displayLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + now.millisecondsSinceEpoch, + ); + } + // else: too small — skip without moving anchor (avoids GPS noise accumulation) + } + } else { + // First point for this delivery — set anchor only + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + displayLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + displayLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + now.millisecondsSinceEpoch, + ); + } + } catch (_) {} + } + + final mqttService = DoormileMqttService(); + if (mqttService.isConnected) { + mqttService.publishLocation(payload); + debugPrint( + '[LIVE TRACKING] ${displayLat.toStringAsFixed(6)}, ${displayLng.toStringAsFixed(6)} ' + '(acc: ${position.accuracy.toStringAsFixed(0)}m, speed: ${position.speed.toStringAsFixed(1)} m/s)', + ); + } else { + debugPrint('[LIVE TRACKING] MQTT not connected — attempting reconnect'); + mqttService.connect(); + } + + // Telemetry once every 5 minutes (timestamp guard prevents duplicate fires + // across the 3-second GPS update window at the same minute mark) + final shouldSendTelemetry = + _lastTelemetrySent == null || + now.difference(_lastTelemetrySent!).inMinutes >= 5; + if (now.minute % 5 == 0 && shouldSendTelemetry) { + _lastTelemetrySent = now; + try { + final battery = Battery(); + final batteryLevel = await battery.batteryLevel; + mqttService.publishTelemetry({ + 'battery_level': batteryLevel, + 'gps_accuracy': position.accuracy, + 'mocked': position.isMocked, + 'timestamp': now.toIso8601String(), + }); + } catch (_) {} + } + } catch (e) { + debugPrint('[LIVE TRACKING] Error: $e'); + } finally { + _isProcessing = false; + } + } +} diff --git a/lib/xpress/controllers/connectivity_mixin.dart b/lib/xpress/controllers/connectivity_mixin.dart new file mode 100644 index 0000000..3ce5705 --- /dev/null +++ b/lib/xpress/controllers/connectivity_mixin.dart @@ -0,0 +1,55 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:get/get.dart'; +import 'package:flutter/foundation.dart'; + +mixin ConnectivityControllerMixin on GetxController { + final RxBool isOnline = true.obs; + Timer? _tick; + + @protected + Future checkInternet() async { + try { + final result = await InternetAddress.lookup('example.com'); + return result.isNotEmpty && result.first.rawAddress.isNotEmpty; + } catch (_) { + return false; + } + } + + @protected + void onConnectivityOnline() {} + + @protected + void onConnectivityOffline() {} + + void _startWatcher() { + _tick?.cancel(); + _tick = Timer.periodic(const Duration(seconds: 5), (_) async { + final ok = await checkInternet(); + final prev = isOnline.value; + if (ok != prev) { + isOnline.value = ok; + if (ok) { + onConnectivityOnline(); + } else { + onConnectivityOffline(); + } + } else { + isOnline.value = ok; + } + }); + } + + @override + void onInit() { + super.onInit(); + _startWatcher(); + } + + @override + void onClose() { + _tick?.cancel(); + super.onClose(); + } +} diff --git a/lib/xpress/controllers/deliveries_controller.dart b/lib/xpress/controllers/deliveries_controller.dart new file mode 100644 index 0000000..720b45e --- /dev/null +++ b/lib/xpress/controllers/deliveries_controller.dart @@ -0,0 +1,2004 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'package:miler/xpress/providers/deliverylog/deliverylog_provider.dart'; +import 'dart:io'; +import 'package:minio/minio.dart'; +import 'package:minio/io.dart'; +import 'dart:math'; +import 'package:miler/xpress/utils/kalman_filter.dart'; +import 'package:miler/xpress/utils/mqtt_service.dart'; +import 'package:miler/xpress/services/maps/doormile_map.dart' show LatLng; +import 'package:miler/xpress/services/maps/routing_service.dart'; +import 'package:miler/xpress/data/delivery_api.dart'; + +class DeliveriesController extends GetxController { + DoormileKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + final UpdateDeliveryProvider _updateProvider = UpdateDeliveryProvider(); + + bool _isSuccess(Map? resp) { + final status = resp?['status']; + if (status is bool) return status; + if (status is String) { + final v = status.toLowerCase(); + return v == 'true' || v == 'success' || v == 'ok' || v == 'accepted'; + } + return false; + } + + // UI flags similar to xpressrider for parity + final RxBool arrivedShimmer = false.obs; + final RxBool deliveredShimmer = false.obs; + final RxBool isPipEnabled = false.obs; + + // Distance tracking for PiP mode + final RxDouble deliverableDistance = 0.0.obs; // Distance in meters + + // Times captured in ISO-like format (yyyy-MM-dd HH:mm:ss) + String _arrivedTime = ''; + String _pickedTime = ''; + String _activeTime = ''; + String _deliveredTime = ''; + String _cancelledTime = ''; + + // Cached last known lat/lng from device + final RxString currentLat = '0'.obs; + final RxString currentLng = '0'.obs; + + // Bonus Points Tracking + final RxInt lastBonusPoints = 0.obs; + + // Trigger to refresh deliveries list across screens + final RxInt refreshTrigger = 0.obs; + + void triggerRefresh() { + refreshTrigger.value++; + } + + // ---------- UPLOAD IMAGE TO DO SPACES ---------- + Future uploadProofImage( + File imageFile, + String folderName, + int userId, + int deliveryId, + ) async { + try { + // final rng = Random(); // Unused + const String region = "sgp1"; + const String accessKey = String.fromEnvironment('SPACES_KEY'); + const String secretKey = String.fromEnvironment('SPACES_SECRET'); + const String bucketName = "doormile"; + // folderName will be "picked" or "delivered" + + // File name + final now = DateTime.now(); + final dateStr = + "${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}"; + final timeStr = + "${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}"; + final String fileName = '$folderName-$deliveryId-$dateStr-$timeStr.jpg'; + + // Object path inside the bucket + // final String objectPath = "$folderName/$fileName"; + final String objectPath = "support/$fileName"; + + // CDN URL you want + final String cdnUrl = DeliveryApi.image(objectPath); + + // Initialize Minio + final minio = Minio( + endPoint: "$region.digitaloceanspaces.com", + accessKey: accessKey, + secretKey: secretKey, + region: region, + useSSL: true, + ); + + debugPrint("Uploading Proof: $objectPath"); + + // Upload to DO Spaces + await minio.fPutObject( + bucketName, + objectPath, + imageFile.path, + metadata: {"Content-Type": "image/jpeg", "x-amz-acl": "public-read"}, + ); + + debugPrint("Proof Uploaded Successfully: $cdnUrl"); + return cdnUrl; + } catch (e) { + debugPrint("Proof Upload error: $e"); + Get.snackbar("Error", "Image upload failed. Please try again."); + return null; + } + } + + // ---------------- Location helpers ---------------- + Future> _ensureLatLng(String lat, String lng) async { + String outLat = lat; + String outLng = lng; + + try { + final needsFetch = + (lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty); + + // Fast path: if we have valid coordinates, use them immediately + if (!needsFetch) return {'lat': outLat, 'lng': outLng}; + + // Reuse recently cached coordinates first if fresh (e.g. within 30s) + // For now just check if they exist to save time + if (currentLat.value.isNotEmpty && + currentLat.value != '0' && + currentLng.value.isNotEmpty && + currentLng.value != '0') { + return {'lat': currentLat.value, 'lng': currentLng.value}; + } + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return {'lat': outLat, 'lng': outLng}; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return {'lat': outLat, 'lng': outLng}; + } + + Position? pos; + + // 1. Try Last Known Position (Instant) + try { + pos = await Geolocator.getLastKnownPosition(); + } catch (_) {} + + // 2. If no last known, try current with a single balanced timeout + // Reduced complicated retry logic to one solid attempt + if (pos == null) { + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: + LocationAccuracy.high, // Better accuracy for deliveries + timeLimit: const Duration(seconds: 4), + ); + } catch (_) { + // Fallback to low accuracy if high fails quickly + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 2), + ); + } catch (_) {} + } + } + + if (pos != null) { + final now = DateTime.now(); + double outLatDouble = pos.latitude; + double outLngDouble = pos.longitude; + + if (_kf == null) { + _kf = DoormileKalmanFilter(lat: outLatDouble, lng: outLngDouble); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + _kf!.predict(dt); + _kf!.update(outLatDouble, outLngDouble); + outLatDouble = _kf!.x[0]; + outLngDouble = _kf!.x[1]; + } + _lastUpdateTime = now; + + outLat = outLatDouble.toStringAsFixed(6); + outLng = outLngDouble.toStringAsFixed(6); + currentLat.value = outLat; + currentLng.value = outLng; + } + + return {'lat': outLat, 'lng': outLng}; + } catch (_) { + return {'lat': outLat, 'lng': outLng}; + } + } + + // Picked + Future updatePickedStatus({ + required int deliveryId, + required int orderHeaderId, + required int pickupLocationId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String address = '', + String city = '', + String state = '', + String suburb = '', + String postcode = '', + String deliveryType = '', + String notes = '', + double actualKms = 0.0, + String? proofImage, // New parameter + }) async { + try { + final now = DateTime.now(); + _pickedTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pLat = double.tryParse(pickupLat) ?? 0.0; + final pLng = double.tryParse(pickupLng) ?? 0.0; + + // Geofence Check: Picked -> Pickup Location + final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Picked'); + if (!inFence) return false; + + // Save pickup location ONLY if it hasn't been saved yet (first pickup only) + final existingPickup = await _getPickupLocation(); + if (existingPickup['lat']!.isEmpty && + pickupLat != '0' && + pickupLng != '0') { + await _savePickupLocation(pickupLat, pickupLng); + } else { + debugPrint('[PICKED] Pickup location already exists, not overwriting'); + } + + // Calculate distance from rider location to pickup location if coordinates are available + double calculatedKms = actualKms; + if (actualKms == 0.0) { + try { + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + + if (riderLat != 0 && riderLng != 0 && pickLat != 0 && pickLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + riderLat, + riderLng, + pickLat, + pickLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[PICKED] Calculated distance: ${calculatedKms.toStringAsFixed(2)} km from rider to pickup', + ); + } + } catch (e) { + debugPrint('[PICKED] Error calculating distance: $e'); + } + } + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'pickuplocationid': pickupLocationId, + 'orderstatus': 'picked', + 'pickuptime': _pickedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': calculatedKms.toStringAsFixed(2), + 'deliveryamt': 0.0, + 'deliverytype': deliveryType, + 'address': address, + 'city': city, + 'state': state, + 'suburb': suburb, + 'postcode': postcode, + 'notes': notes, + 'pickupimage': proofImage ?? '', // Standard key + 'proofimage': proofImage ?? '', // Fallback/Alternative key just in case + }; + + debugPrint('[PICKED] Payload: $payload'); + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updatePickedDelivery(payload, url); + final ok = _isSuccess(resp); + + if (!ok) { + debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + DoormileMqttService().publishLog('delivery_picked', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][PICKED][ERROR] $e'); + return false; + } + } + + // Delivered + Future updateDeliveredStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + int deliveryLocationId = 0, + int smsDelivery = 0, + String pickupLat = '0', + String pickupLng = '0', + String deliveryLat = '0', + String deliveryLng = '0', + String notes = '', + double deliveryAmount = 0.0, + double actualKms = 0.0, + double collectionAmount = 0.0, + double collectedAmount = 0.0, + int collectionStatus = 0, + bool wasSkipped = false, + String orderId = '', // New parameter for timer/bonus logic + String? proofImage, // New parameter + }) async { + try { + deliveredShimmer.value = true; + // ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately + final prefsFuture = SharedPreferences.getInstance(); + + final now = DateTime.now(); + _deliveredTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + + // Geofence Check: Delivered -> Delivery Location + final inFence = await _checkGeofence(dLat, dLng, rLat, rLng, 'Delivered'); + if (!inFence) { + deliveredShimmer.value = false; + return false; + } + + debugPrint( + '[DELIVERED] Rider GPS location: lat=${ll['lat']}, lng=${ll['lng']}', + ); + + // Wait for prefs to be ready + final prefs = await prefsFuture; + + // ---------------- RIDER TIME (minutes) ---------------- + int riderTimeMinutes = 0; + try { + final key = 'ridertime_start_$deliveryId'; + final startStr = prefs.getString(key); + if (startStr != null && startStr.isNotEmpty) { + final start = DateTime.tryParse(startStr); + if (start != null) { + final diff = now.difference(start).inSeconds / 60.0; + if (diff > 0) { + // Round: 2.7 -> 3, 2.5 -> 3, 2.4 -> 2, etc. + riderTimeMinutes = diff.round(); + } + } + } + debugPrint('[DELIVERED] riderTimeMinutes=$riderTimeMinutes'); + } catch (e) { + debugPrint('[DELIVERED] Error computing ridertime: $e'); + } + + // CRITICAL: Always calculate riderkms - even for small distances (10 meters = 0.01 km) + // This ensures riderkms is ALWAYS passed correctly, never null + double calculatedRiderKms = 0.0; + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + + // PRIORITY 1: Use cumulative distance tracked from active to delivered (most accurate) + // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds + try { + final cumulativeKmStr = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKmStr.isNotEmpty) { + final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; + if (cumulativeKm > 0) { + calculatedRiderKms = cumulativeKm; + debugPrint( + '[DELIVERED] ✅ Using CUMULATIVE tracked distance: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + debugPrint( + '[DELIVERED] 📊 This is the actual distance traveled from active to delivered', + ); + } + } + } catch (e) { + debugPrint('[DELIVERED] Error getting cumulative distance: $e'); + } + + // PRIORITY 2: Only use actualKms if cumulative distance not available and it's greater than 0 + if (calculatedRiderKms == 0.0 && + actualKms > 0.0 && + riderLat != 0 && + riderLng != 0) { + calculatedRiderKms = actualKms; + debugPrint( + '[DELIVERED] Using provided actualKms: ${calculatedRiderKms.toStringAsFixed(2)} km', + ); + } + + // Always try to calculate distance if we have valid rider coordinates + // CRITICAL: Use Google Maps Directions API for accurate ROAD distance (not straight-line) + if (calculatedRiderKms == 0.0 && riderLat != 0 && riderLng != 0) { + try { + // Method 1: Try Start Location (Anti-Cheat) - most accurate + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && startLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Start Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Start Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 2: If start location failed, try provided pickup location + if (calculatedRiderKms == 0.0 && + pickupLat != '0' && + pickupLng != '0') { + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + if (pickLat != 0 && pickLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 3: Fallback to saved pickup location + if (calculatedRiderKms == 0.0) { + final pickupLoc = await _getPickupLocation(); + final pickLat = double.tryParse(pickupLoc['lat'] ?? '') ?? 0.0; + final pickLng = double.tryParse(pickupLoc['lng'] ?? '') ?? 0.0; + if (pickLat != 0 && pickLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Saved Pickup): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Saved Pickup): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 4: Final fallback - calculate from delivery location to current location + if (calculatedRiderKms == 0.0 && + deliveryLat != '0' && + deliveryLng != '0') { + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + if (dLat != 0 && dLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + dLat, + dLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Delivery Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + dLat, + dLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Delivery Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + } catch (e) { + debugPrint('[DELIVERED] Error calculating distance: $e'); + } + } + + // CRITICAL: Ensure riderkms is never 0 or null - use minimum value if calculation failed + // Even 10 meters should be shown (0.01 km) + if (calculatedRiderKms == 0.0) { + debugPrint( + '[DELIVERED] ⚠️ WARNING: Could not calculate distance, using minimum 0.01 km (10 meters)', + ); + calculatedRiderKms = 0.01; // Minimum 10 meters + } + + debugPrint( + '[DELIVERED] Final riderkms: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + + // ---- BONUS POINTS LOGIC ---- + int bonusPoints = 0; + if (orderId.isNotEmpty) { + try { + final endKey = 'eta_endtime_$orderId'; + final endSeconds = prefs.getInt(endKey); + + if (endSeconds != null) { + final currentSeconds = + DateTime.now().millisecondsSinceEpoch ~/ 1000; + // If delivered before or at the end time, give bonus points + if (currentSeconds <= endSeconds) { + // Bonus points = riderkms rounded + bonusPoints = calculatedRiderKms.round(); + debugPrint( + '[DELIVERED] 🏆 ON TIME! Bonus Points: $bonusPoints (RiderKms: $calculatedRiderKms)', + ); + } else { + debugPrint( + '[DELIVERED] ⏳ LATE! No Bonus Points. (Deadline: $endSeconds, Now: $currentSeconds)', + ); + } + } else { + debugPrint('[DELIVERED] No ETA timer found for bonus logic.'); + } + } catch (e) { + debugPrint('[DELIVERED] Error calculating bonus points: $e'); + } + } + + final double riderChargeRate = await _getRiderChargeRate(); + + // ---- SKIP PENALTY CHECK ---- + // If the rider has exceeded skip limits in the last 3 hours, forfeit bonus points + final skipStatus = await checkSkipStatus(); + if (skipStatus['penalty'] == true) { + if (bonusPoints > 0) { + debugPrint( + '[DELIVERED] ⚠️ Bonus points ($bonusPoints) forfeited due to skip penalty.', + ); + bonusPoints = 0; + } + } + + final double riderChargesAmountRaw = + riderChargeRate > 0 && calculatedRiderKms > 0 + ? calculatedRiderKms * riderChargeRate + : 0.0; + // Ensure ridercharges is sent with 2 decimal places (Decimal(10,2) style) + final double riderChargesAmount = double.parse( + riderChargesAmountRaw.toStringAsFixed(2), + ); + + // CRITICAL: Always set deliverytime to current time right before creating payload + // This ensures deliverytime is ALWAYS passed when confirming delivery, no matter what + // Setting it here (right before payload) ensures it's the exact time of confirmation + final currentTime = DateTime.now(); + final currentDeliveryTime = _formatDateTimeFull(currentTime); + _deliveredTime = currentDeliveryTime; // Update the instance variable too + + debugPrint('[DELIVERED] Setting deliverytime to: $currentDeliveryTime'); + + // CRITICAL: Ensure riderkms is always a valid string, never null or empty + final riderKmsString = calculatedRiderKms > 0 + ? calculatedRiderKms.toStringAsFixed( + 4, + ) // Use 4 decimals to show even 10 meters (0.0100 km) + : '0.0100'; // Fallback minimum (10 meters) + + // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null + final ridersLatStr = ll['lat'] ?? '0'; + final ridersLngStr = ll['lng'] ?? '0'; + final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; + final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; + + if (ridersLatDouble == 0 || + ridersLngDouble == 0 || + ridersLatDouble.abs() > 90 || + ridersLngDouble.abs() > 180) { + debugPrint( + '[DELIVERED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', + ); + deliveredShimmer.value = false; + return false; // Don't send invalid coordinates + } + + // Validate delivery coordinates + final deliveryLatDouble = double.tryParse(deliveryLat) ?? 0.0; + final deliveryLngDouble = double.tryParse(deliveryLng) ?? 0.0; + if (deliveryLatDouble == 0 || + deliveryLngDouble == 0 || + deliveryLatDouble.abs() > 90 || + deliveryLngDouble.abs() > 180) { + debugPrint( + '[DELIVERED] ❌ ERROR: Invalid delivery coordinates (lat=$deliveryLat, lng=$deliveryLng) - cannot proceed', + ); + deliveredShimmer.value = false; + return false; // Don't send invalid coordinates + } + + debugPrint( + '[DELIVERED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr), Delivery: ($deliveryLat, $deliveryLng)', + ); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'deliverylocationid': deliveryLocationId, + 'orderstatus': 'delivered', + 'deliveredtime': currentDeliveryTime, + 'deliverytime': + currentDeliveryTime, // ALWAYS pass current time - required field, set right before payload + 'smsdelivery': smsDelivery, + 'riderslat': ridersLatStr, // Guaranteed non-zero and valid + 'riderslon': ridersLngStr, // Guaranteed non-zero and valid + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'pickuplat': pickupLat, + 'pickuplong': pickupLng, + 'deliverylat': deliveryLat, // Guaranteed non-zero and valid + 'deliverylong': deliveryLng, // Guaranteed non-zero and valid + 'riderkms': + riderKmsString, // ALWAYS pass riderkms - never null, shows even 10 meters (0.0100 km) + 'ridercharges': riderChargesAmount, + 'deliveryamt': deliveryAmount, + 'collectionamt': collectionAmount, + 'collectedamt': collectedAmount, + 'collectionstatus': collectionStatus, + 'ridertime': riderTimeMinutes, + 'notes': notes, + 'wasskipped': wasSkipped, + 'bonuspts': bonusPoints, // ✅ ADDED BONUS POINTS + 'dropimage': proofImage ?? '', // Include in payload + }; + + // Update observable for UI + lastBonusPoints.value = bonusPoints; + + debugPrint( + '[DELIVERED] Payload includes deliverytime: $currentDeliveryTime', + ); + debugPrint('[DELIVERED] Payload includes riderkms: $riderKmsString km'); + debugPrint('[DELIVERED] Payload includes bonuspts: $bonusPoints'); + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + // Save current RIDER GPS location as last delivery location for next delivery + if (ok) { + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + + // CRITICAL: Clean up cumulative distance tracking after delivery is completed + try { + final cumulativeKm = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[DELIVERED] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); + await prefs.remove('ridertime_start_$deliveryId'); + await prefs.remove('delivery_proximity_alerted_$deliveryId'); + + final activeTracking = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (activeTracking.contains(deliveryId.toString())) { + activeTracking.remove(deliveryId.toString()); + await prefs.setStringList( + 'active_tracking_delivery_ids', + activeTracking, + ); + } + + // ✅ Clean up ETA timer + if (orderId.isNotEmpty) { + await prefs.remove('eta_endtime_$orderId'); + } + debugPrint( + '[DELIVERED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[DELIVERED] Error cleaning up tracking: $e'); + } + } + + // Reset foreground delivery notification flag + await setNotificationSent(false); + deliveredShimmer.value = false; + + if (!ok) { + debugPrint('[UPDATE][DELIVERED][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + DoormileMqttService().publishLog('delivery_completed', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][DELIVERED][ERROR] $e'); + deliveredShimmer.value = false; + return false; + } + } + + // ---------------- Date/Time helpers ---------------- + String _two(int n) => n.toString().padLeft(2, '0'); + + String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + String _resolveUpdateUrl() { + return ApiConstants.mainRoute == 'live' + ? ApiConstants.updateDeliveryLive + : ApiConstants.updateDeliveryDev; + } + + // ---------------- SharedPrefs helpers ---------------- + Future setNotificationSent(bool value) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('notificationSent', value); + } catch (_) {} + } + + Future getLogSecondsOrDefault([int fallback = 60]) async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('logSeconds') ?? fallback; + } catch (_) { + return fallback; + } + } + + Future persistDeliveryLogSnapshot( + List> logs, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('deliveryLog', jsonEncode(logs)); + } catch (_) {} + } + + // ---------------- Geofencing helpers ---------------- + Future _getDeliveryRadius() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('deliveryradius') ?? 100; + } catch (_) { + return 100; + } + } + + // Helper method to show snackbar reliably in both debug and release builds + void _showErrorSnackbar( + String title, + String message, { + Color? bgColor, + int seconds = 4, + int retryCount = 0, + }) { + // Prevent infinite recursion + if (retryCount > 3) { + print('[GEOFENCE] Max retries reached for snackbar. Title: $title'); + return; + } + + try { + final context = Get.key.currentContext; + + // Get bottom safe area padding + final double bottomSafePadding = context != null + ? MediaQuery.of(context).padding.bottom + : 0; + + Get.snackbar( + title, + message, + backgroundColor: bgColor ?? Colors.red, + colorText: Colors.white, + duration: Duration(seconds: seconds), + snackPosition: SnackPosition.BOTTOM, + isDismissible: true, + shouldIconPulse: true, + margin: EdgeInsets.fromLTRB( + 12, + 0, + 12, + 12 + bottomSafePadding, // SafeArea bottom + ), + borderRadius: 10, + maxWidth: 400, + forwardAnimationCurve: Curves.easeOutBack, + reverseAnimationCurve: Curves.easeInBack, + ); + } catch (e) { + print( + '[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e', + ); + print( + '[GEOFENCE] GetX context available: ${Get.key.currentContext != null}', + ); + + Future.delayed(const Duration(milliseconds: 400), () { + try { + Get.snackbar( + title, + message, + backgroundColor: bgColor ?? Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } catch (e2) { + print('[GEOFENCE] Retry snackbar failed: $e2'); + + if (retryCount < 2) { + Future.delayed(const Duration(milliseconds: 300), () { + _showErrorSnackbar( + title, + message, + bgColor: bgColor, + seconds: seconds, + retryCount: retryCount + 1, + ); + }); + } + } + }); + } + } + + Future _checkGeofence( + double targetLat, + double targetLng, + double currentLat, + double currentLng, + String action, + ) async { + // Validate coordinates - ensure they are valid GPS coordinates + final bool hasValidTarget = + targetLat != 0 && + targetLng != 0 && + targetLat.abs() <= 90 && + targetLng.abs() <= 180; + final bool hasValidCurrent = + currentLat != 0 && + currentLng != 0 && + currentLat.abs() <= 90 && + currentLng.abs() <= 180; + + if (!hasValidTarget || !hasValidCurrent) { + // If coordinates are missing or invalid, show error and block + if (kDebugMode) { + debugPrint( + '[GEOFENCE] Missing or invalid coordinates for $action. Target: ($targetLat, $targetLng), Current: ($currentLat, $currentLng)', + ); + } + + // Show warning snackbar using helper method + _showErrorSnackbar( + 'Location Warning', + 'Missing coordinates. Proceeding with update.', + bgColor: Colors.orange, + seconds: 3, + ); + return true; // Allow update to proceed despite missing coords + } + + try { + final radius = await _getDeliveryRadius(); + final distance = Geolocator.distanceBetween( + targetLat, + targetLng, + currentLat, + currentLng, + ); + final distanceKm = distance / 1000.0; + final radiusKm = radius / 1000.0; + final distanceMeters = distance; + + if (kDebugMode) { + debugPrint( + '[GEOFENCE] Action: $action | Target: ($targetLat, $targetLng) | Current: ($currentLat, $currentLng) | Distance: ${distanceMeters.toStringAsFixed(1)}m (${distanceKm.toStringAsFixed(3)}km) | Radius: ${radius}m (${radiusKm.toStringAsFixed(3)}km)', + ); + } + + if (distance > radius) { + // Show error snackbar with clear message using helper method + _showErrorSnackbar( + 'Location Error', + 'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m', + seconds: 5, + ); + return false; + } + return true; + } catch (e) { + if (kDebugMode) { + debugPrint('[GEOFENCE] Error calculating distance: $e'); + } + // On error, show warning but allow (fail-safe) + _showErrorSnackbar( + 'Location Warning', + 'Unable to verify distance. Proceeding with caution.', + bgColor: Colors.orange, + seconds: 3, + ); + return true; // Allow on error (fail-safe) + } + } + + // Get last delivery location (for calculating riderkms between deliveries) + Future> _getLastDeliveryLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('lastDeliveryLat') ?? ''; + final lng = prefs.getString('lastDeliveryLng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Save last delivery location (rider's actual GPS location, not destination) + Future _saveLastDeliveryLocation(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('lastDeliveryLat', lat); + await prefs.setString('lastDeliveryLng', lng); + debugPrint('[TRACKING] Saved last delivery location: lat=$lat, lng=$lng'); + } catch (e) { + debugPrint('[TRACKING] Error saving last delivery location: $e'); + } + } + + // Save start location for a specific delivery (when "Start Navigation" is slid) + Future _saveDeliveryStartLocation( + int deliveryId, + String lat, + String lng, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('delivery_start_${deliveryId}_lat', lat); + await prefs.setString('delivery_start_${deliveryId}_lng', lng); + debugPrint( + '[TRACKING] Saved start location for delivery $deliveryId: lat=$lat, lng=$lng', + ); + } catch (e) { + debugPrint('[TRACKING] Error saving delivery start location: $e'); + } + } + + // Get start location for a specific delivery + Future> _getDeliveryStartLocation(int deliveryId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('delivery_start_${deliveryId}_lat') ?? ''; + final lng = prefs.getString('delivery_start_${deliveryId}_lng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Get pickup location (for first delivery riderkms calculation) + Future> _getPickupLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('pickupLat') ?? ''; + final lng = prefs.getString('pickupLng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Get road distance from the OSRM routing backend (actual road distance, not + // straight-line). Returns kilometres, or null on failure so callers fall back + // to the straight-line estimate. + // + // Identical origin/destination pairs are served from the routing service's + // cache, so repeated quotes for the same delivery cost one request. + Future _getRouteDistanceKm( + double originLat, + double originLng, + double destLat, + double destLng, + ) async { + final distanceKm = await RoutingService.getRouteDistanceKm( + origin: LatLng(originLat, originLng), + destination: LatLng(destLat, destLng), + ); + + if (distanceKm == null) { + debugPrint('[ROUTE] No route distance available, using fallback'); + return null; + } + + debugPrint('[ROUTE] Route distance: ${distanceKm.toStringAsFixed(4)} km'); + return distanceKm; + } + + // Save pickup location + Future _savePickupLocation(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pickupLat', lat); + await prefs.setString('pickupLng', lng); + debugPrint('[TRACKING] Saved pickup location: lat=$lat, lng=$lng'); + } catch (e) { + debugPrint('[TRACKING] Error saving pickup location: $e'); + } + } + + Future _getRiderChargeRate() async { + try { + final prefs = await SharedPreferences.getInstance(); + // Prefer new fuelcharge key; fall back to legacy firstmilecharge if needed + Object? raw = prefs.get('fuelcharge'); + raw ??= prefs.get('firstmilecharge'); + if (raw is double) return raw; + if (raw is int) return raw.toDouble(); + if (raw is String) { + return double.tryParse(raw) ?? 0.0; + } + } catch (_) {} + return 0.0; + } + + // Clear delivery tracking (when all deliveries are done or starting fresh) + Future clearDeliveryTracking() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('lastDeliveryLat'); + await prefs.remove('lastDeliveryLng'); + await prefs.remove('pickupLat'); + await prefs.remove('pickupLng'); + debugPrint('[TRACKING] Cleared all delivery tracking data'); + } catch (e) { + debugPrint('[TRACKING] Error clearing delivery tracking: $e'); + } + } + + // ---------------- Status Updates ---------------- + + // Accepted + Future updateAcceptedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final acceptedTime = _formatDateTimeFull(DateTime.now()); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'accepted', + 'acceptedtime': acceptedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + if (!ok) { + // Log entire response for debugging + try { + debugPrint('[UPDATE][ACCEPTED][FAILED] resp=${jsonEncode(resp)}'); + } catch (_) {} + } + return ok; + } catch (e) { + debugPrint('[UPDATE][ACCEPTED][ERROR] $e'); + return false; + } + } + + // Active (start navigation) + Future updateActiveStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + String orderId = '', + }) async { + try { + final now = DateTime.now(); + _activeTime = _formatDateTimeFull(now); + final prefs = await SharedPreferences.getInstance(); + String startTime = + prefs.getString('delivery_starttime_$deliveryId') ?? ''; + + if (startTime.isEmpty) { + startTime = _formatDateTimeFull(now); + } else { + debugPrint('[UPDATE][ACTIVE] Using preserved starttime: $startTime'); + } + + // Ensure we never send 0/0 for rider location. + Map ll = await _ensureLatLng(ridersLat, ridersLng); + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + try { + final last = await Geolocator.getLastKnownPosition(); + if (last != null) { + ll = { + 'lat': last.latitude.toStringAsFixed(6), + 'lng': last.longitude.toStringAsFixed(6), + }; + } + } catch (_) {} + } + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + try { + final pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 3), + ); + ll = { + 'lat': pos.latitude.toStringAsFixed(6), + 'lng': pos.longitude.toStringAsFixed(6), + }; + } catch (_) {} + } + + // If we still don't have a valid location, bail out to avoid sending 0/0. + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + debugPrint('[UPDATE][ACTIVE] No valid rider coordinates; aborting'); + return false; + } + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'active', + 'activetime': _activeTime, + 'starttime': startTime, // Add starttime when status changes to active + 'activelat': ll['lat'], + 'activelon': ll['lng'], + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateActiveDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + final lat = ll['lat'] ?? '0'; + final lng = ll['lng'] ?? '0'; + if (lat != '0' && lng != '0') { + await _saveDeliveryStartLocation(deliveryId, lat, lng); + } + + // Save starttime to SharedPreferences for delivery logs (using deliveryId as key) + // This will be retrieved when creating delivery log payloads + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('delivery_starttime_$deliveryId', startTime); + if (orderId.isNotEmpty) { + await prefs.setString('current_riding_order_id', orderId); + } + await prefs.setString( + 'current_riding_delivery_id', + deliveryId.toString(), + ); + debugPrint( + '[UPDATE][ACTIVE] Saved starttime: $startTime for deliveryId: $deliveryId', + ); + + // CRITICAL: Initialize cumulative distance tracking when delivery becomes active + // Reset any previous tracking data and set initial location + await prefs.setString('delivery_tracking_${deliveryId}_lastLat', lat); + await prefs.setString('delivery_tracking_${deliveryId}_lastLng', lng); + await prefs.setString( + 'delivery_tracking_${deliveryId}_cumulativeKm', + '0.0', + ); + await prefs.setInt( + 'delivery_tracking_${deliveryId}_lastUpdateMs', + DateTime.now().millisecondsSinceEpoch, + ); + + final activeTracking = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (!activeTracking.contains(deliveryId.toString())) { + activeTracking.add(deliveryId.toString()); + await prefs.setStringList( + 'active_tracking_delivery_ids', + activeTracking, + ); + } + + debugPrint( + '[UPDATE][ACTIVE] 🚀 Initialized distance tracking for deliveryId: $deliveryId at ($lat, $lng)', + ); + } catch (e) { + debugPrint('[UPDATE][ACTIVE] Error saving starttime: $e'); + } + } else { + debugPrint('[UPDATE][ACTIVE][FAILED] resp=${jsonEncode(resp)}'); + } + + return ok; + } catch (e) { + debugPrint('[UPDATE][ACTIVE][ERROR] $e'); + return false; + } + } + + // Arrived + Future updateArrivedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String notes = '', + }) async { + // START LOADING IMMEDIATELY for better UX + arrivedShimmer.value = true; + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pLat = double.tryParse(pickupLat) ?? 0.0; + final pLng = double.tryParse(pickupLng) ?? 0.0; + + // Geofence Check: Arrived -> Pickup Location + final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Arrived'); + if (!inFence) { + arrivedShimmer.value = false; + return false; + } + + final now = DateTime.now(); + _arrivedTime = _formatDateTimeFull(now); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'arrived', + 'arrivaltime': _arrivedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateArrivedDelivery(payload, url); + final ok = _isSuccess(resp); + + // Stop shimmer immediately after response + arrivedShimmer.value = false; + + if (!ok) { + debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][ARRIVED][ERROR] $e'); + arrivedShimmer.value = false; + return false; + } + } + + // Rejected (remove from queue) + Future updateRejectedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'rejected', + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (!ok) { + debugPrint('[UPDATE][REJECTED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][REJECTED][ERROR] $e'); + return false; + } + } + + // Cancelled (delivery cancelled by rider) + Future updateCancelledStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String deliveryLat = '0', + String deliveryLng = '0', + String notes = '', + double actualKms = 0.0, + bool wasSkipped = false, + }) async { + try { + final now = DateTime.now(); + _cancelledTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + + // ✅ SPECIAL CASE: Skip geofence check for "Incorrect Location" cancellation + // When user cancels due to incorrect location, we don't verify range + final bool isIncorrectLocation = notes.toLowerCase().contains( + 'incorrect location', + ); + + if (isIncorrectLocation) { + debugPrint( + '[CANCELLED] ⚠️ Skipping geofence check - Reason: Incorrect Location', + ); + } else { + // Geofence Check: Cancelled -> Delivery Location (for all other reasons) + final inFence = await _checkGeofence( + dLat, + dLng, + riderLat, + riderLng, + 'Cancelled', + ); + if (!inFence) { + debugPrint( + '[CANCELLED] ❌ Geofence check failed - Rider not in range', + ); + return false; + } + } + + // PRIORITY 1: Use cumulative distance tracked from active to cancelled (most accurate) + // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds + double calculatedKms = 0.0; + try { + final prefs = await SharedPreferences.getInstance(); + final cumulativeKmStr = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKmStr.isNotEmpty) { + final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; + if (cumulativeKm > 0) { + calculatedKms = cumulativeKm; + debugPrint( + '[CANCELLED] ✅ Using CUMULATIVE tracked distance: ${calculatedKms.toStringAsFixed(4)} km', + ); + debugPrint( + '[CANCELLED] 📊 This is the actual distance traveled from active to cancelled', + ); + } + } + } catch (e) { + debugPrint('[CANCELLED] Error getting cumulative distance: $e'); + } + + // PRIORITY 2: Fallback to actualKms if cumulative not available + if (calculatedKms == 0.0) { + calculatedKms = actualKms; + } + + if (wasSkipped && calculatedKms == 0.0) { + try { + final lastDelivery = await _getLastDeliveryLocation(); + final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; + if (lastLat != 0 && lastLng != 0 && riderLat != 0 && riderLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] Skipped delivery - rider travelled ${calculatedKms.toStringAsFixed(2)} km from last stop to cancellation point', + ); + } + } catch (e) { + debugPrint('[CANCELLED] Error calculating skipped distance: $e'); + } + } + + if (calculatedKms == 0.0) { + try { + // ANTI-CHEAT: Try to get the locked "Start Location" for this delivery + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && + startLng != 0 && + riderLat != 0 && + riderLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[CANCELLED] ✅ Route distance (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km from Start Location to cancellation point', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] ⚠️ Fallback straight-line (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } else { + // Fallback to Pickup -> Current + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + if (pickLat != 0 && + pickLng != 0 && + riderLat != 0 && + riderLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[CANCELLED] ✅ Route distance: ${calculatedKms.toStringAsFixed(4)} km from pickup to cancellation point', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] ⚠️ Fallback straight-line: ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } + } catch (e) { + debugPrint('[CANCELLED] Error calculating distance: $e'); + } + } + + final double riderChargeRate = await _getRiderChargeRate(); + final double riderChargesAmountRaw = + riderChargeRate > 0 && calculatedKms > 0 + ? calculatedKms * riderChargeRate + : 0.0; + final double riderChargesAmount = double.parse( + riderChargesAmountRaw.toStringAsFixed(2), + ); + if (riderChargeRate > 0) { + debugPrint( + '[CANCELLED] Applying rider charge rate $riderChargeRate => ridercharges ${riderChargesAmount.toStringAsFixed(2)}', + ); + } + + // CRITICAL: Clean up cumulative distance tracking after cancellation + try { + final prefs = await SharedPreferences.getInstance(); + final cumulativeKm = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[CANCELLED] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); + await prefs.remove('delivery_proximity_alerted_$deliveryId'); + + final activeTracking = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (activeTracking.contains(deliveryId.toString())) { + activeTracking.remove(deliveryId.toString()); + await prefs.setStringList( + 'active_tracking_delivery_ids', + activeTracking, + ); + } + + debugPrint( + '[CANCELLED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[CANCELLED] Error cleaning up tracking: $e'); + } + + // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null + final ridersLatStr = ll['lat'] ?? '0'; + final ridersLngStr = ll['lng'] ?? '0'; + final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; + final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; + + if (ridersLatDouble == 0 || + ridersLngDouble == 0 || + ridersLatDouble.abs() > 90 || + ridersLngDouble.abs() > 180) { + debugPrint( + '[CANCELLED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', + ); + return false; // Don't send invalid coordinates + } + + debugPrint( + '[CANCELLED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr)', + ); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'cancelled', + 'canceltime': _cancelledTime, + 'riderslat': ridersLatStr, // Guaranteed non-zero and valid + 'riderslon': ridersLngStr, // Guaranteed non-zero and valid + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'riderkms': calculatedKms.toStringAsFixed(2), + 'ridercharges': riderChargesAmount, + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + // Update last delivery location to current location so next order starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + } + + if (!ok) { + debugPrint('[UPDATE][CANCELLED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][CANCELLED][ERROR] $e'); + return false; + } + } + + // Skipped (delivery skipped by rider) + Future updateSkippedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final skippedTime = _formatDateTimeFull(DateTime.now()); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'skipped', + 'skippedtime': skippedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + // Calculate distance for skipped order (Anti-Cheat) + double calculatedKms = 0.0; + try { + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + + if (riderLat != 0 && riderLng != 0) { + // Try Start Location first + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && startLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[SKIPPED] ✅ Route distance (Start Location): ${calculatedKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[SKIPPED] ⚠️ Fallback straight-line (Start Location): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } else { + // Fallback to Last Delivery Location + final lastDelivery = await _getLastDeliveryLocation(); + final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; + if (lastLat != 0 && lastLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + lastLat, + lastLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[SKIPPED] ✅ Route distance (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[SKIPPED] ⚠️ Fallback straight-line (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } + } + } catch (e) { + debugPrint('[SKIPPED] Error calculating distance: $e'); + } + + if (calculatedKms > 0) { + payload['riderkms'] = calculatedKms.toStringAsFixed(2); + debugPrint('[SKIPPED] Calculated riderkms: ${payload['riderkms']}'); + } + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + // Update last delivery location to current location so next order starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + } + + if (!ok) { + debugPrint('[UPDATE][SKIPPED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][SKIPPED][ERROR] $e'); + return false; + } + } + + /// Check if there are active deliveries + /// Returns true ONLY if has_live_deliveries is true (verified by API) + /// This ensures PiP is only enabled when there are actually active deliveries + Future hasActiveDeliveries() async { + try { + final prefs = await SharedPreferences.getInstance(); + final hasLive = prefs.getBool('has_live_deliveries') ?? false; + + // ✅ CRITICAL: Only return true if has_live_deliveries is true + // Don't rely on active_delivery_order_id alone - it may be stale + // The API sets has_live_deliveries based on actual order status = "active" + if (!hasLive) { + // Clear stale active_delivery_order_id if has_live_deliveries is false + final activeOrderId = prefs.getString('active_delivery_order_id'); + if (activeOrderId != null && activeOrderId.isNotEmpty) { + debugPrint( + '[DELIVERIES_CONTROLLER] Clearing stale active_delivery_order_id: $activeOrderId (no active deliveries)', + ); + await prefs.remove('active_delivery_order_id'); + } + } + + debugPrint( + '[DELIVERIES_CONTROLLER] hasActiveDeliveries: $hasLive (has_live_deliveries from API)', + ); + return hasLive; + } catch (e) { + debugPrint( + '[DELIVERIES_CONTROLLER] Error checking active deliveries: $e', + ); + return false; + } + } + + // ---------------- Skip Penalty Logic ---------------- + + /// Checks the current skip count and penalty status within the 3-hour window. + /// Resets the window if 3 hours have passed since the first skip. + Future> checkSkipStatus() async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + final startStr = prefs.getString('skip_window_start'); + int count = prefs.getInt('skip_count') ?? 0; + bool penalty = prefs.getBool('skip_penalty_active') ?? false; + + if (startStr != null) { + final start = DateTime.tryParse(startStr); + if (start != null) { + final diff = now.difference(start); + if (diff.inHours >= 3) { + debugPrint( + '[SKIP_LOGIC] 3-hour window expired (elapsed: ${diff.inMinutes}m). Resetting skips.', + ); + // Reset window + count = 0; + penalty = false; + await prefs.remove('skip_window_start'); + await prefs.remove('skip_count'); + await prefs.remove('skip_penalty_active'); + } + } else { + // Invalid date string, reset + await prefs.remove('skip_window_start'); + } + } + + return {'count': count, 'penalty': penalty}; + } catch (e) { + debugPrint('[SKIP_LOGIC] Error checking status: $e'); + return {'count': 0, 'penalty': false}; + } + } + + /// Registers a skip action. + /// Increments skip count and sets penalty if specified. + /// Starts 3-hour window if not already active. + Future registerSkip({bool applyPenalty = false}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + + // Ensure we are working with fresh/current state + await checkSkipStatus(); + + // Re-fetch after potential reset + int count = prefs.getInt('skip_count') ?? 0; + String? startStr = prefs.getString('skip_window_start'); + + if (startStr == null) { + // Start new window + await prefs.setString('skip_window_start', now.toIso8601String()); + debugPrint('[SKIP_LOGIC] Starting new 3-hour skip window.'); + } + + count++; + await prefs.setInt('skip_count', count); + + if (applyPenalty) { + await prefs.setBool('skip_penalty_active', true); + debugPrint('[SKIP_LOGIC] Penalty activated for this session.'); + } + + debugPrint( + '[SKIP_LOGIC] Skip registered. Count: $count, Penalty: $applyPenalty', + ); + } catch (e) { + debugPrint('[SKIP_LOGIC] Error registering skip: $e'); + } + } +} diff --git a/lib/xpress/controllers/delivery.dart b/lib/xpress/controllers/delivery.dart new file mode 100644 index 0000000..fd8b8bf --- /dev/null +++ b/lib/xpress/controllers/delivery.dart @@ -0,0 +1,123 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DeliveryController extends GetxController { + // Exposed reactive fields if needed in UI + final RxString orderId = ''.obs; + final RxString orderStatus = ''.obs; + final RxInt tenantId = 0.obs; + final RxInt partnerId = 0.obs; + final RxInt locationId = 0.obs; + final RxInt orderHeaderId = 0.obs; + final RxInt deliveryId = 0.obs; + final RxInt userId = 0.obs; + final RxString latitude = ''.obs; + final RxString longitude = ''.obs; + + // Preference keys (aligned with existing usage in app) + static const String kTenantId = 'delivery_tenantid'; + static const String kPartnerId = 'delivery_partnerid'; + static const String kLocationId = 'delivery_locationid'; + static const String kOrderHeaderId = 'delivery_orderheaderid'; + static const String kDeliveryId = 'delivery_deliveryid'; + static const String kUserId = 'delivery_userid'; + static const String kOrderId = 'delivery_orderid'; + static const String kOrderStatus = 'delivery_orderstatus'; + static const String kLat = 'delivery_latitude'; + static const String kLng = 'delivery_longitude'; + + Future saveFromQueueItem(Map delivery) async { + final prefs = await SharedPreferences.getInstance(); + + final int? tenantid = delivery['tenantid'] is int + ? delivery['tenantid'] as int + : int.tryParse('${delivery['tenantid'] ?? ''}'); + final int? partnerid = delivery['partnerid'] is int + ? delivery['partnerid'] as int + : int.tryParse('${delivery['partnerid'] ?? ''}'); + final int? locationid = delivery['locationid'] is int + ? delivery['locationid'] as int + : int.tryParse('${delivery['locationid'] ?? ''}'); + final int? orderheaderid = delivery['orderheaderid'] is int + ? delivery['orderheaderid'] as int + : int.tryParse('${delivery['orderheaderid'] ?? ''}'); + final int? deliveryid = delivery['deliveryid'] is int + ? delivery['deliveryid'] as int + : int.tryParse('${delivery['deliveryid'] ?? ''}'); + final int? userid = delivery['userid'] is int + ? delivery['userid'] as int + : int.tryParse('${delivery['userid'] ?? ''}'); + + final String oid = (delivery['orderid'] ?? '').toString(); + final String ostatus = (delivery['orderstatus'] ?? '').toString(); + + // Prefer deliverylat/long, fallback to droplat/lon + final String lat = (delivery['deliverylat'] ?? delivery['droplat'] ?? '') + .toString(); + final String lon = (delivery['deliverylong'] ?? delivery['droplon'] ?? '') + .toString(); + + if (tenantid != null) await prefs.setInt(kTenantId, tenantid); + if (partnerid != null) await prefs.setInt(kPartnerId, partnerid); + if (locationid != null) await prefs.setInt(kLocationId, locationid); + if (orderheaderid != null) + await prefs.setInt(kOrderHeaderId, orderheaderid); + if (deliveryid != null) await prefs.setInt(kDeliveryId, deliveryid); + if (userid != null) await prefs.setInt(kUserId, userid); + if (oid.isNotEmpty) await prefs.setString(kOrderId, oid); + if (ostatus.isNotEmpty) await prefs.setString(kOrderStatus, ostatus); + if (lat.isNotEmpty) await prefs.setString(kLat, lat); + if (lon.isNotEmpty) await prefs.setString(kLng, lon); + + // Update observables + orderId.value = oid; + orderStatus.value = ostatus; + tenantId.value = tenantid ?? 0; + partnerId.value = partnerid ?? 0; + locationId.value = locationid ?? 0; + orderHeaderId.value = orderheaderid ?? 0; + deliveryId.value = deliveryid ?? 0; + userId.value = userid ?? 0; + latitude.value = lat; + longitude.value = lon; + } + + Future loadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + tenantId.value = prefs.getInt(kTenantId) ?? 0; + partnerId.value = prefs.getInt(kPartnerId) ?? 0; + locationId.value = prefs.getInt(kLocationId) ?? 0; + orderHeaderId.value = prefs.getInt(kOrderHeaderId) ?? 0; + deliveryId.value = prefs.getInt(kDeliveryId) ?? 0; + userId.value = prefs.getInt(kUserId) ?? 0; + orderId.value = prefs.getString(kOrderId) ?? ''; + orderStatus.value = prefs.getString(kOrderStatus) ?? ''; + latitude.value = prefs.getString(kLat) ?? ''; + longitude.value = prefs.getString(kLng) ?? ''; + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(kTenantId); + await prefs.remove(kPartnerId); + await prefs.remove(kLocationId); + await prefs.remove(kOrderHeaderId); + await prefs.remove(kDeliveryId); + await prefs.remove(kUserId); + await prefs.remove(kOrderId); + await prefs.remove(kOrderStatus); + await prefs.remove(kLat); + await prefs.remove(kLng); + + orderId.value = ''; + orderStatus.value = ''; + tenantId.value = 0; + partnerId.value = 0; + locationId.value = 0; + orderHeaderId.value = 0; + deliveryId.value = 0; + userId.value = 0; + latitude.value = ''; + longitude.value = ''; + } +} diff --git a/lib/xpress/controllers/logcontroller.dart b/lib/xpress/controllers/logcontroller.dart new file mode 100644 index 0000000..5e30a11 --- /dev/null +++ b/lib/xpress/controllers/logcontroller.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:get/get.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/xpress/providers/deliverylog/deliverylog_provider.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'package:miler/xpress/background/foreground_service.dart' as fg; +import 'package:geolocator/geolocator.dart'; + +/// Controller for managing active delivery logs +/// Now delegates to the background service for actual logging +class LogController extends GetxController { + final CreateDeliveryLogProvider _logProvider = CreateDeliveryLogProvider(); + + static const String _offlineLogKey = 'offline_delivery_logs'; + bool _isFlushing = false; + + /// Start the delivery log streaming service (via foreground service) + Future startLogging() async { + debugPrint('[ACTIVE_DELIVERY_LOG] Requesting start logging...'); + + // Attempt to flush offline logs on start + flushOfflineLogs(); + + // Only show foreground notification when rider is actually on duty + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint( + '[ACTIVE_DELIVERY_LOG] Skipping startLogging because onduty=$onduty', + ); + return; + } + } catch (_) { + // If prefs fail, continue with best-effort start + } + + if (Platform.isAndroid) { + if (await FlutterForegroundTask.isRunningService) { + debugPrint('[ACTIVE_DELIVERY_LOG] Foreground service already running'); + return; + } + + debugPrint( + '[ACTIVE_DELIVERY_LOG] Starting foreground service for delivery logs', + ); + + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'doormile_bg_service', + channelName: 'Background Service', + channelDescription: + 'Keeps Doormile online updates running in background.', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: true, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + interval: 30000, // 30 seconds + isOnceEvent: false, + autoRunOnBoot: false, + allowWakeLock: true, + allowWifiLock: true, + ), + ); + + // Check permissions before starting service to prevent Android 14 crash + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint( + '[ACTIVE_DELIVERY_LOG] Location permission missing, skipping service start', + ); + return; + } + + try { + await FlutterForegroundTask.startService( + notificationTitle: 'Doormile is running', + notificationText: 'You are Currently on Duty !', + callback: fg.riderLogCallback, + ); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG] Failed to start service: $e'); + } + } else { + debugPrint( + '[ACTIVE_DELIVERY_LOG] iOS/Web not fully supported for background service yet', + ); + } + } + + /// Stop the delivery log streaming service + /// Note: This might stop rider logs too if they share the service. + /// Usually we only stop if the user goes off-duty or logs out. + void stopLogging() { + debugPrint( + '[ACTIVE_DELIVERY_LOG] Stop logging requested (no-op to preserve rider logs)', + ); + // We do not stop the service here because it might be running for Rider Logs. + // The service should be stopped by RiderLogController when going off-duty. + } + + // ---------------- Offline Queue Logic (Foreground Helper) ---------------- + + /// Call this on app start or network restoration + Future flushOfflineLogs() async { + if (_isFlushing) return; + _isFlushing = true; + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + if (queue.isEmpty) return; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + // Determine API endpoint + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + for (final itemStr in queue) { + try { + final Map item = jsonDecode(itemStr); + final String orderId = item['orderId'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (payload.isEmpty) continue; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Retrying for orderId: $orderId', + ); + + final result = await _logProvider + .createDeliveryLog(url, payload) + .timeout(const Duration(seconds: 8)); + + if (result != null) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Success for orderId: $orderId', + ); + anySuccess = true; + } else { + remaining.add(itemStr); + } + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Error processing item: $e', + ); + remaining.add(itemStr); + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Flush complete. Remaining: ${remaining.length}', + ); + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][OFFLINE] Flush error: $e'); + } finally { + _isFlushing = false; + } + } +} diff --git a/lib/xpress/controllers/profile_controller.dart b/lib/xpress/controllers/profile_controller.dart new file mode 100644 index 0000000..ab564c6 --- /dev/null +++ b/lib/xpress/controllers/profile_controller.dart @@ -0,0 +1,44 @@ +import 'dart:io'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ProfileController extends GetxController { + final RxString imagePath = ''.obs; + final RxString userName = ''.obs; + final RxString userEmail = ''.obs; + final RxString userContact = ''.obs; + final RxString userAddress = ''.obs; + + void setImagePath(String path) { + imagePath.value = path; + } + + Future loadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + userName.value = (prefs.getString('user_name') ?? '').trim(); + userEmail.value = (prefs.getString('user_email') ?? '').trim(); + userContact.value = (prefs.getString('contactno') ?? '').trim(); + userAddress.value = (prefs.getString('user_address') ?? '').trim(); + } + + void setProfile({ + String? name, + String? email, + String? contact, + String? address, + }) { + if (name != null && name.trim().isNotEmpty) userName.value = name.trim(); + if (email != null && email.trim().isNotEmpty) + userEmail.value = email.trim(); + if (contact != null && contact.trim().isNotEmpty) + userContact.value = contact.trim(); + if (address != null && address.trim().isNotEmpty) + userAddress.value = address.trim(); + } + + File? get fileOrNull { + final path = imagePath.value; + if (path.isEmpty) return null; + return File(path); + } +} diff --git a/lib/xpress/controllers/rewards_controller.dart b/lib/xpress/controllers/rewards_controller.dart new file mode 100644 index 0000000..3749296 --- /dev/null +++ b/lib/xpress/controllers/rewards_controller.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:miler/xpress/data/delivery_api.dart'; + +class RewardsController extends GetxController { + final RxInt totalPoints = 0.obs; + final RxBool isLoading = true.obs; + final RxString error = ''.obs; + + Future fetchBonusSummary(int userId) async { + try { + isLoading.value = true; + // Using dynamic userid passed from ProfilePage + final url = Uri.parse('${DeliveryApi.bonusSummary}?userid=$userId'); + + final response = await http.get( + url, + headers: {'Accept': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data is Map && data['status'] == true) { + // Check 'details' first, then 'data' + final dynamic content = data['details'] ?? data['data']; + + if (content is Map) { + // "bonuspts": 40 + totalPoints.value = + int.tryParse((content['bonuspts'] ?? '0').toString()) ?? 0; + debugPrint('[REWARDS] Fetched bonuspts: ${totalPoints.value}'); + } else if (content is List && content.isNotEmpty) { + final first = content.first; + if (first is Map) { + totalPoints.value = + int.tryParse((first['bonuspts'] ?? '0').toString()) ?? 0; + } + } + } + } else { + error.value = 'Failed to load rewards'; + } + } catch (e) { + error.value = e.toString(); + debugPrint('[REWARDS] Error: $e'); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/xpress/controllers/riderkm.dart b/lib/xpress/controllers/riderkm.dart new file mode 100644 index 0000000..f64e6c2 --- /dev/null +++ b/lib/xpress/controllers/riderkm.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:miler/xpress/Models/summary/riderweeklykms.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; + +class RiderWeeklyKmController { + final String baseUrl = ApiConstants.summaryriderkmLive; + + Future> getRiderWeeklyKms(int userId) async { + final url = Uri.parse("$baseUrl/getriderweeklykms?userid=$userId"); + final response = await http.get(url); + + if (response.statusCode == 200) { + final body = json.decode(response.body); + if (body['status'] == true) { + final details = (body['details'] as List) + .map((e) => RiderWeeklyKms.fromJson(e)) + .toList(); + + return { + 'details': details, + 'total_kms': double.tryParse('${body['total_kms'] ?? 0}') ?? 0.0, + }; + } else { + throw Exception(body['message'] ?? "API returned false status"); + } + } else { + throw Exception("Failed to fetch (code: ${response.statusCode})"); + } + } +} diff --git a/lib/xpress/controllers/riderlog.dart b/lib/xpress/controllers/riderlog.dart new file mode 100644 index 0000000..9732bbb --- /dev/null +++ b/lib/xpress/controllers/riderlog.dart @@ -0,0 +1,1287 @@ +import 'package:get/get.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:convert'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'package:miler/xpress/Models/riders/riders_models.dart'; +import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:async'; +import 'package:geolocator/geolocator.dart'; +import 'dart:math'; +import 'dart:io' show Platform; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:miler/xpress/helpers/shift_end_alarm.dart'; +import 'package:miler/xpress/controllers/logcontroller.dart'; +import 'package:miler/xpress/background/foreground_service.dart' as fg; +import 'package:miler/xpress/utils/kalman_filter.dart'; +import 'package:miler/xpress/utils/mqtt_service.dart'; +import 'package:miler/xpress/views/helpers/constants/mqtt_constants.dart'; +import 'package:battery_plus/battery_plus.dart'; + +class RiderLogController extends GetxController { + final _createProvider = CreateRiderLogProvider(); + final _updateProvider = UpdateRiderLogProvider(); + final _getProvider = GetRiderLogProvider(); + final _breakProvider = BreakRiderLogProvider(); + + final RxBool isLoading = false.obs; + final RxList riderLogs = [].obs; + final RxInt riderCount = 0.obs; + bool _isEnsuringSession = false; + bool _startBreakInFlight = false; + bool _endBreakInFlight = false; + StreamSubscription? _autoLoginSubscription; + StreamSubscription? _batterySubscription; + bool _lowBatteryAlertSent = false; + + DoormileKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + Future fetchRiderLogs({required String userId}) async { + isLoading.value = true; + try { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.getRiderLogLive + : ApiConstants.getRiderLogDev; + final url = "$base?userid=$userId"; + final response = await _getProvider.getRiderLog(url); + + // Handle either {details: {...}} or {data: [...]} shapes + if (response == null) { + riderLogs.clear(); + } else if (response['details'] is Map) { + final Map details = + response['details'] as Map; + // Only accept if non-zero logid or userid present + final int logIdVal = int.tryParse('${details['logid'] ?? 0}') ?? 0; + if (logIdVal > 0) { + riderLogs.value = [RiderLog.fromJson(details)]; + } else { + riderLogs.clear(); + } + } else if (response['data'] is List) { + final List dataList = response['data'] as List; + riderLogs.value = dataList + .whereType>() + .map((e) => RiderLog.fromJson(e)) + .toList(); + } else { + riderLogs.clear(); + } + + // Persist key identifiers for later API calls (only if valid) + try { + if (riderLogs.isNotEmpty) { + final RiderLog current = riderLogs.first; + final prefs = await SharedPreferences.getInstance(); + if ((current.userid ?? 0) > 0) { + await prefs.setInt('userid', current.userid!); + } + if ((current.partnerid ?? 0) > 0) { + await prefs.setInt('partnerid', current.partnerid!); + await prefs.setInt('partnerId', current.partnerid!); + } + if ((current.shiftid ?? 0) > 0) { + await prefs.setInt('shiftid', current.shiftid!); + await prefs.setInt('shiftId', current.shiftid!); + } + if ((current.logid ?? 0) > 0) { + await prefs.setInt('logid', current.logid!); + await prefs.setInt('logId', current.logid!); + } + if (current.onduty != null) { + await prefs.setInt('onduty', current.onduty!); + } + if ((current.tenantid ?? 0) > 0) { + await prefs.setInt('tenantid', current.tenantid!); + } + if ((current.locationid ?? 0) > 0) { + await prefs.setInt('locationid', current.locationid!); + } + if ((current.applocationid ?? 0) > 0) { + await prefs.setInt('applocationid', current.applocationid!); + } + if (current.userfcmtoken != null && + current.userfcmtoken!.isNotEmpty) { + await prefs.setString('userfcmtoken', current.userfcmtoken!); + } + + // Persist full rider log template from server for future rider log payloads. + // Latitude/longitude will always be overridden with real GPS values when sending. + try { + await prefs.setString( + 'riderlog_template', + jsonEncode(current.toJson()), + ); + } catch (_) {} + } + } catch (_) {} + } finally { + isLoading.value = false; + } + } + + Future fetchRiderCount({required String userId}) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.getRiderCountLive + : ApiConstants.getRiderCountDev; + final url = "$base?userid=$userId"; + final response = await _getProvider.getRiderCount(url); + final dynamic cnt = response != null ? response['count'] : null; + riderCount.value = cnt is num + ? cnt.toInt() + : int.tryParse('${cnt ?? 0}') ?? 0; + } + + Future createLogin(RiderLogin login) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + final ok = await _createProvider.createRiderLog( + base, + _CreateRiderLogRequestCompat.fromRiderLogin(login), + ); + return ok != null && ok.isNotEmpty; + } + + Future updateLog(RiderUpdate update) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateRiderLogLive + : ApiConstants.updateRiderLogDev; + final ok = await _updateProvider.updateRiderLog( + base, + _UpdateRiderLogRequestCompat.fromRiderUpdate(update), + ); + return ok != null && ok.isNotEmpty; + } + + Future createBreak(RiderBreak brk) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createBreakRiderLogLive + : ApiConstants.createBreakRiderLogDev; + final ok = await _breakProvider.createBreakRiderLog( + base, + _BreakLogRequestCompat.fromRiderBreak(brk), + ); + return ok != null && ok.isNotEmpty; + } + + Future updateBreak(RiderBreak brk) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateBreakRiderLogLive + : ApiConstants.updateBreakRiderLogDev; + final ok = await _breakProvider.updateBreakRiderLog( + base, + _BreakLogRequestCompat.fromRiderBreak(brk), + ); + return ok != null && ok.isNotEmpty; + } + + Future updateBreakCustom({ + required int breakid, + required int logid, + required String breakdate, + required int userid, + required int partnerid, + required int shiftid, + required String breakstart, + required String breakend, + required double breakhours, + required String latitude, + required String longitude, + }) async { + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateBreakRiderLogLive + : ApiConstants.updateBreakRiderLogDev; + + final payload = { + "breakid": breakid, + "logid": logid, + "breakdate": breakdate, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "breakstart": breakstart, + "breakend": breakend, + "breakhours": breakhours, + "latitude": latitude, + "longitude": longitude, + }; + + try { + // Debug: show exact URL and JSON body + debugPrint('[BREAK][UPDATE] URL: $url'); + debugPrint('[BREAK][UPDATE] Body: $payload'); + + final response = await _breakProvider.updateBreakRiderLog(url, payload); + + // Debug: show raw response + debugPrint( + '[BREAK][UPDATE] Response: ${response == null ? 'null' : response.toString()}', + ); + return response != null && response.isNotEmpty; + } catch (e) { + return false; + } + } + + // Public helper: create login immediately after successful auth + Future createLoginNowV2() async { + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Skipped because onduty=$onduty', + ); + return false; + } + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = + prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + if ((userid ?? 0) == 0) return false; + + // ✅ Check if there are active deliveries to set status + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + final String orderId = prefs.getString('current_riding_order_id') ?? ''; + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Active deliveries: $hasActiveDeliveries -> status: $riderStatus', + ); + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); // e.g. 2025-10-30 15:00:00 + final loginTime = _formatTime(now); // e.g. 12:00:00 + final loc = await _ensureLatLng('0', '0'); + + // Start from rider log template stored after login, then override + // dynamic fields like date/time and GPS coordinates. + Map baseTemplate = {}; + try { + final rawTemplate = prefs.getString('riderlog_template'); + if (rawTemplate != null && rawTemplate.isNotEmpty) { + final decoded = jsonDecode(rawTemplate); + if (decoded is Map) { + baseTemplate = decoded; + } else if (decoded is Map) { + baseTemplate = decoded.cast(); + } + } + } catch (_) {} + + final Map payload = { + // Base from server template + ...baseTemplate, + // Always ensure required identifiers are correct + 'logid': baseTemplate['logid'], // let server decide new logid + 'userid': userid, + 'partnerid': partnerid, + 'shiftid': shiftid, + // Always override with current time and real GPS + 'logdate': iso, + 'login': loginTime, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'raw_latitude': loc['raw_lat'] ?? '0', + 'raw_longitude': loc['raw_lng'] ?? '0', + 'velocity_lat': loc['velocity_lat'] ?? '0', + 'velocity_lng': loc['velocity_lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'onduty': 1, + + 'status': riderStatus, + 'contactno': prefs.getString('contactno') ?? '', + 'tenantid': tenantid ?? 0, + 'locationid': locationid ?? 0, + 'applocationid': applocationid ?? 0, + 'userfcmtoken': userfcmtoken ?? '', + 'orderid': orderId, + }; + + // --- MQTT LOGIC ( Lane Split ) --- + final mqttService = DoormileMqttService(); + if (!mqttService.isConnected) { + await mqttService.connect(); + } + + // 1. Lane: Status + mqttService.updateStatus( + riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, + ); + + // 2. Lane: Profile (Send only if significantly changed or first time) + // For simplicity, we send it here once when duty starts or during logs, + // but in a separate topic so it doesn't clutter the GPS stream. + mqttService.publishProfile({ + 'userid': userid, + 'username': (username ?? '').trim(), + 'firstname': prefs.getString('firstname') ?? '', + 'lastname': prefs.getString('lastname') ?? '', + 'contactno': prefs.getString('contactno') ?? '', + 'userfcmtoken': userfcmtoken ?? '', + 'app_version': prefs.getString('CurrentVersion') ?? '', + 'device_info': Platform.operatingSystem, + }); + + // 3. Lane: Periodic Log (Lightweight) + mqttService.publishLog('rider_periodic_log', { + 'userid': userid, + 'logdate': iso, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'status': riderStatus, + 'orderid': orderId, + }); + + // Ensure rider identity fields are clean: + // - always send username key (even if empty) so backend sees it + payload['username'] = (username ?? '').trim(); + + final String cNo = prefs.getString('contactno') ?? ''; + debugPrint('[RIDERLOG] Contact No from Prefs: "$cNo"'); + payload['contactno'] = cNo; + + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } else { + payload.remove('firstname'); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } else { + payload.remove('lastname'); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + debugPrint('[RIDERLOG][CREATE LOGIN NOW] URL: $base'); + debugPrint('[RIDERLOG][CREATE LOGIN NOW] Payload: $payload'); + + final resp = await _createProvider.createRiderLog(base, payload); + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Response: ${resp == null ? 'null' : resp.toString()}', + ); + + if (resp == null || resp.isEmpty) { + // Offline fallback + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Failed, saving to offline queue', + ); + await _saveToOfflineQueue(base, payload); + return false; + } + + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + final newLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + if (newLogId > 0) { + await prefs.setInt('logId', newLogId); + await prefs.setInt('logid', newLogId); + } + + // Attempt to flush any other pending logs since we have a success + flushOfflineLogs(); + + return true; + } catch (_) { + // Offline fallback on exception + try { + final prefs = await SharedPreferences.getInstance(); + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = + prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + if ((userid ?? 0) != 0) { + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + final loc = await _ensureLatLng('0', '0'); + + // Offline fallback also uses stored template plus real GPS/time. + Map baseTemplate = {}; + try { + final rawTemplate = prefs.getString('riderlog_template'); + if (rawTemplate != null && rawTemplate.isNotEmpty) { + final decoded = jsonDecode(rawTemplate); + if (decoded is Map) { + baseTemplate = decoded; + } else if (decoded is Map) { + baseTemplate = decoded.cast(); + } + } + } catch (_) {} + + // Check status for offline fallback too + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + + final Map payload = { + ...baseTemplate, + 'logid': baseTemplate['logid'], + 'userid': userid, + 'partnerid': partnerid, + 'shiftid': shiftid, + 'logdate': iso, + 'login': loginTime, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'raw_latitude': loc['raw_lat'] ?? '0', + 'raw_longitude': loc['raw_lng'] ?? '0', + 'velocity_lat': loc['velocity_lat'] ?? '0', + 'velocity_lng': loc['velocity_lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'onduty': 1, + 'status': riderStatus, + 'contactno': prefs.getString('contactno') ?? '', + 'tenantid': tenantid ?? 0, + 'locationid': locationid ?? 0, + 'applocationid': applocationid ?? 0, + 'userfcmtoken': userfcmtoken ?? '', + }; + + // Ensure rider identity fields are clean in offline payload too + // Always send username key (even if empty) for parity with online payload + payload['username'] = (username ?? '').trim(); + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } else { + payload.remove('firstname'); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } else { + payload.remove('lastname'); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + await _saveToOfflineQueue(base, payload); + } + } catch (e) { + debugPrint('[RIDERLOG] Error saving offline log: $e'); + } + return false; + } + } + + // Toggle onduty via updateLog + Future setOnDuty(bool on) async { + try { + final prefs = await SharedPreferences.getInstance(); + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + if ((userid ?? 0) == 0) return false; + final loc = await _ensureLatLng('0', '0'); + final payload = RiderUpdate( + userid: userid, + onduty: on ? 1 : 0, + latitude: loc['lat'] ?? '0', + longitude: loc['lng'] ?? '0', + ); + debugPrint('[RIDERLOG][SET ONDUTY] -> ${payload.toJson()}'); + final ok = await updateLog(payload); + if (ok) { + await prefs.setInt('onduty', on ? 1 : 0); + if (on) { + await createLoginNowV2(); + final int interval = prefs.getInt('logseconds') ?? 0; + if (interval > 0) { + startAutoCreateLoginLoop(seconds: interval); + } + + // Ensure foreground logging notification is started when going on-duty + try { + if (Get.isRegistered()) { + await Get.find().startLogging(); + } + } catch (_) {} + + // ✅ Schedule shift end alarm (works even when app is killed) + try { + final String endTime = prefs.getString('endtime') ?? ''; + final String startTime = prefs.getString('starttime') ?? ''; + debugPrint( + '[RIDERLOG][SET ONDUTY] 📅 Checking shift times - endTime: "$endTime", startTime: "$startTime"', + ); + if (endTime.isNotEmpty) { + debugPrint( + '[RIDERLOG][SET ONDUTY] 📅 Scheduling shift end alarm for: $endTime', + ); + final scheduled = await ShiftEndAlarm.scheduleAlarm( + endTime: endTime, + startTime: startTime, + ); + if (scheduled) { + debugPrint( + '[RIDERLOG][SET ONDUTY] ✅ Successfully scheduled shift end alarm for: $endTime', + ); + } else { + debugPrint( + '[RIDERLOG][SET ONDUTY] ⚠️ Failed to schedule shift end alarm for: $endTime', + ); + } + } else { + debugPrint( + '[RIDERLOG][SET ONDUTY] ⚠️ endTime is empty - cannot schedule alarm', + ); + } + } catch (e) { + debugPrint('[RIDERLOG][SET ONDUTY] ❌ Error scheduling alarm: $e'); + } + + _startBatteryMonitoring(); + // Force MQTT connection on duty start + DoormileMqttService().connect(); + } else { + await stopAutoCreateLoginLoop(); + _stopBatteryMonitoring(); + + // ✅ Cancel shift end alarm when going offline + try { + await ShiftEndAlarm.cancelAlarm(); + debugPrint('[RIDERLOG][SET ONDUTY] ✅ Cancelled shift end alarm'); + } catch (e) { + debugPrint('[RIDERLOG][SET ONDUTY] Error cancelling alarm: $e'); + } + + // --- MQTT LOGIC --- + // disconnect() publishes Offline status then fully closes the connection, + // freeing the broker slot and resetting _currentRiderId for a clean reconnect. + DoormileMqttService().disconnect(); + } + } + return ok; + } catch (_) { + return false; + } + } + + // Start periodic createRiderLog calls based on seconds (or prefs 'logseconds') + // ✅ CRITICAL: When there are active deliveries, use 30 seconds (same as delivery logs) + // Otherwise, use the configured logseconds interval + void startAutoCreateLoginLoop({int? seconds}) async { + final prefs = await SharedPreferences.getInstance(); + + // ✅ Check if there are active deliveries - if yes, use 30 seconds (same as delivery logs) + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final int baseInterval = seconds ?? (prefs.getInt('logseconds') ?? 0); + + // When there are active deliveries, post rider logs every 30 seconds (matching delivery logs) + // Otherwise, use the configured interval + final int interval = hasActiveDeliveries ? 30 : baseInterval; + + if (hasActiveDeliveries && interval != 30) { + debugPrint( + '[RIDERLOG][AUTO LOOP] Active delivery detected - using 30 second interval (matching delivery logs)', + ); + } + + await stopAutoCreateLoginLoop(); // Cancel existing subscription + + // Attempt to flush offline logs on loop start + flushOfflineLogs(); + + if (interval <= 0) return; + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty'); + return; + } + + if (Platform.isAndroid) { + try { + // Check if we can use foreground service (Android 14+ restrictions) + // Initialize and start Android foreground service for reliable background ticks + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'doormile_bg_service', + channelName: 'Background Service', + channelDescription: + 'Keeps Doormile online updates running in background.', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: true, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + interval: Duration(seconds: interval).inMilliseconds, + isOnceEvent: false, + autoRunOnBoot: false, + allowWakeLock: true, + allowWifiLock: true, + ), + ); + + // Check permissions before starting service to prevent Android 14 crash + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint( + '[RIDERLOG] Location permission missing, cannot start foreground service', + ); + _startStreamBasedLoop(interval); + return; + } + + // Try to start the service with error handling + ServiceRequestResult? started; + try { + started = await FlutterForegroundTask.startService( + notificationTitle: 'Doormile is running', + notificationText: 'Auto rider log active', + callback: fg.riderLogCallback, + ); + } catch (error) { + // If foreground service fails, fall back to stream-based approach + debugPrint( + '[RIDERLOG] Foreground service failed, using stream fallback: $error', + ); + } + + // If foreground service failed to start, use stream fallback + if (started != ServiceRequestResult.success) { + debugPrint( + '[RIDERLOG] Using stream-based periodic updates instead of foreground service', + ); + _startStreamBasedLoop(interval); + return; + } + } catch (e) { + // If any error occurs, fall back to stream-based approach + debugPrint( + '[RIDERLOG] Error starting foreground service, using stream fallback: $e', + ); + _startStreamBasedLoop(interval); + return; + } + } else { + _startStreamBasedLoop(interval); + } + } + + // Stream-based periodic loop (fallback when foreground service is unavailable) + void _startStreamBasedLoop(int interval) { + _autoLoginSubscription?.cancel(); + _autoLoginSubscription = + Stream.periodic(Duration(seconds: interval), (_) {}) + .asyncMap((_) async { + await createLoginNowV2(); + }) + .listen( + (_) {}, // Success handler + onError: (error) { + // Handle errors gracefully without crashing + debugPrint('[RIDERLOG][STREAM ERROR] $error'); + }, + cancelOnError: false, // Continue even on errors + ); + } + + Future stopAutoCreateLoginLoop() async { + if (Platform.isAndroid) { + try { + await FlutterForegroundTask.stopService(); + } catch (e) { + debugPrint('[RIDERLOG] Error stopping foreground service: $e'); + } + } + await _autoLoginSubscription?.cancel(); + _autoLoginSubscription = null; + } + + void _startBatteryMonitoring() { + _batterySubscription?.cancel(); + final battery = Battery(); + + _batterySubscription = battery.onBatteryStateChanged.listen(( + BatteryState state, + ) async { + final level = await battery.batteryLevel; + if (level <= 20 && !_lowBatteryAlertSent) { + DoormileMqttService().publishLog('critical_battery', { + 'level': level, + 'state': state.toString(), + 'rider_id': riderLogs.isNotEmpty ? riderLogs.first.userid : 'unknown', + 'timestamp': DateTime.now().toIso8601String(), + }); + _lowBatteryAlertSent = true; + debugPrint('[MQTT] ⚠️ Critical Battery Alert Sent: $level%'); + } else if (level > 25) { + _lowBatteryAlertSent = false; // Reset if they started charging + } + }); + } + + void _stopBatteryMonitoring() { + _batterySubscription?.cancel(); + _batterySubscription = null; + } + + @override + void onClose() { + stopAutoCreateLoginLoop(); + _stopBatteryMonitoring(); + super.onClose(); + } + + // ---------------- Break flow parity with xpressrider ---------------- + + String _two(int n) => n.toString().padLeft(2, '0'); + + String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + String _formatTime(DateTime dt) { + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$hh:$mm:$ss"; + } + + // ignore: unused_element + String _durationToHourDotMinute(Duration d) { + final hours = d.inHours; + final minutes = d.inMinutes.remainder(60); + return "$hours.${_two(minutes)}"; // e.g. 1.30 + } + + Future> _ensureLatLng(String lat, String lng) async { + Map result = { + 'lat': lat, + 'lng': lng, + 'raw_lat': lat, + 'raw_lng': lng, + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + try { + final needsFetch = + (lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty); + if (!needsFetch) return result; + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return result; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return result; + } + + final Position pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + final now = DateTime.now(); + double outLat = pos.latitude; + double outLng = pos.longitude; + double speed = pos.speed; + double heading = pos.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (pi / 180.0); + final double velocityLng = speed * sin(headingRadians); + final double velocityLat = speed * cos(headingRadians); + + if (_kf == null) { + _kf = DoormileKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + return { + 'lat': outLat.toStringAsFixed(6), + 'lng': outLng.toStringAsFixed(6), + 'raw_lat': pos.latitude.toStringAsFixed(6), + 'raw_lng': pos.longitude.toStringAsFixed(6), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + }; + } catch (_) { + return result; + } + } + + Future startBreakAuto({ + String latitude = '0', + String longitude = '0', + }) async { + try { + if (_startBreakInFlight) return false; + _startBreakInFlight = true; + // Resolve location if not provided + final ll = await _ensureLatLng(latitude, longitude); + latitude = ll['lat'] ?? latitude; + longitude = ll['lng'] ?? longitude; + + final prefs = await SharedPreferences.getInstance(); + int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + + // Ensure a rider log session exists (no GET calls) + if ((userid ?? 0) == 0 || + (partnerid ?? 0) == 0 || + (shiftid ?? 0) == 0 || + (logid ?? 0) == 0) { + final ok = await _ensureLogSession(); + if (ok) { + userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + } + } + + // Default missing optional IDs to 0, require at least userid and a logid + partnerid = partnerid ?? 0; + shiftid = shiftid ?? 0; + // If still missing, fall back to zeros to allow call and see server response + userid = userid ?? 0; + logid = logid ?? 0; + + final now = DateTime.now(); + final int localBreakId = + Random().nextInt(900) + 100; // 3-digit ID: 100-999 + final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16 + final breakstart = _formatTime(now); // e.g. 16:36:16 + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createBreakRiderLogLive + : ApiConstants.createBreakRiderLogDev; + + // Build payload with all required fields in the exact format the API expects + final payload = { + "breakid": localBreakId, + "logid": logid, + "breakdate": breakdate, // YYYY-MM-DD HH:MM:SS + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "breakstart": breakstart, // HH:MM:SS + "breakend": "", + "breakhours": 0.0, + "latitude": latitude, + "longitude": longitude, + }; + + // Debug logs for terminal visibility + debugPrint('[BREAK][CREATE] URL: $url'); + debugPrint('[BREAK][CREATE] Payload: $payload'); + + // Persist local break id immediately to ensure update can reference it + await prefs.setInt('breakId', localBreakId); + + final resp = await _breakProvider.createBreakRiderLog(url, payload); + + debugPrint( + '[BREAK][CREATE] Response: ${resp == null ? 'null' : resp.toString()}', + ); + if (resp == null || resp.isEmpty) return false; + + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + + await prefs.setString( + 'breakStart', + (det['breakstart'] ?? breakstart).toString(), + ); + final dynamic rawId = det['breakid'] ?? det['message_id'] ?? 0; + final int serverBreakId = int.tryParse('$rawId') ?? 0; + if (serverBreakId > 0) { + await prefs.setInt('breakId', serverBreakId); + } + final int persistedLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + await prefs.setInt('logId', persistedLogId); + await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); + + return true; + } catch (_) { + return false; + } finally { + _startBreakInFlight = false; + } + } + + Future endBreakAuto({ + String latitude = '0', + String longitude = '0', + }) async { + try { + if (_endBreakInFlight) return false; + _endBreakInFlight = true; + // Resolve location if not provided + final ll = await _ensureLatLng(latitude, longitude); + latitude = ll['lat'] ?? latitude; + longitude = ll['lng'] ?? longitude; + + final prefs = await SharedPreferences.getInstance(); + int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + int? breakid = prefs.getInt('breakId'); + final startEpoch = prefs.getInt('break_start_epoch'); + final savedBreakStart = prefs.getString('breakStart') ?? ''; + + // Ensure a rider log session exists (no GET calls) + if ((userid ?? 0) == 0 || + (partnerid ?? 0) == 0 || + (shiftid ?? 0) == 0 || + (logid ?? 0) == 0) { + final ok = await _ensureLogSession(); + if (ok) { + userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + } + } + + // Default missing optional IDs to 0; require userid, logid, breakid, start time + partnerid = partnerid ?? 0; + shiftid = shiftid ?? 0; + // If missing, default to zeros so we still hit API and get response for visibility + userid = userid ?? 0; + logid = logid ?? 0; + breakid = breakid ?? 0; + + if (breakid == 0) { + // No break in progress; nothing to update. + // Remove any stale tracking data and exit gracefully. + await prefs.remove('break_start_epoch'); + await prefs.remove('breakId'); + debugPrint( + '[BREAK][UPDATE] Skipped endBreakAuto because breakId is 0/missing', + ); + return true; + } + + final now = DateTime.now(); + final breakdate = _formatDateTimeFull(now); + final breakend = _formatTime(now); + + final int startEpochVal = + startEpoch ?? DateTime.now().millisecondsSinceEpoch; + final startTime = DateTime.fromMillisecondsSinceEpoch(startEpochVal); + final duration = now.difference(startTime); + final breakhoursDouble = duration.inSeconds / 3600.0; + + // Debug logs for terminal visibility + debugPrint( + '[BREAK][UPDATE] URL: ${ApiConstants.mainRoute == 'live' ? ApiConstants.updateBreakRiderLogLive : ApiConstants.updateBreakRiderLogDev}', + ); + debugPrint( + '[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude', + ); + + return await updateBreakCustom( + breakid: breakid, + logid: logid, + breakdate: breakdate, + userid: userid, + partnerid: partnerid, + shiftid: shiftid, + breakstart: savedBreakStart, + breakend: breakend, + breakhours: breakhoursDouble, + latitude: latitude, + longitude: longitude, + ); + } catch (_) { + return false; + } finally { + _endBreakInFlight = false; + } + } + + // Ensure rider log session by creating login if missing; persists logid/ids. + Future _ensureLogSession() async { + try { + if (_isEnsuringSession) return false; + _isEnsuringSession = true; + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint('[RIDERLOG][ENSURE SESSION] Skipped because onduty=$onduty'); + return false; + } + int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + if ((userid ?? 0) == 0) return false; + if ((partnerid ?? 0) == 0) return false; + if ((shiftid ?? 0) == 0) return false; + if ((logid ?? 0) > 0) return true; + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + // ✅ Check if there are active deliveries to set status + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + + // Resolve rider display name (username) from prefs to include in log + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + // Resolve current location for accurate login lat/lng + final loc = await _ensureLatLng('0', '0'); + final payload = RiderLogin( + userid: userid, + partnerid: partnerid, + shiftid: shiftid, + logdate: iso.substring(0, 10), + login: loginTime, + latitude: loc['lat'] ?? '0', + longitude: loc['lng'] ?? '0', + onduty: 1, + status: riderStatus, // ✅ Add status field: "active" or "idle" + username: username, + tenantid: tenantid ?? 0, + locationid: locationid ?? 0, + applocationid: applocationid ?? 0, + userfcmtoken: userfcmtoken ?? '', + ).toJson(); + + debugPrint('[RIDERLOG][CREATE LOGIN] URL: $url'); + debugPrint('[RIDERLOG][CREATE LOGIN] Payload: $payload'); + + final resp = await _createProvider.createRiderLog(url, payload); + debugPrint( + '[RIDERLOG][CREATE LOGIN] Response: ${resp == null ? 'null' : resp.toString()}', + ); + if (resp == null || resp.isEmpty) return false; + + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + + final newLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + if (newLogId > 0) { + await prefs.setInt('logId', newLogId); + await prefs.setInt('logid', newLogId); + return true; + } + return false; + } catch (_) { + return false; + } finally { + _isEnsuringSession = false; + } + } + + // ---------------- Offline Queue Logic ---------------- + + static const String _offlineLogKey = 'offline_rider_logs'; + bool _isFlushing = false; + + /// Call this on app start or network restoration + Future flushOfflineLogs() async { + if (_isFlushing) return; + _isFlushing = true; + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + if (queue.isEmpty) return; + + debugPrint( + '[RIDERLOG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + for (final itemStr in queue) { + try { + final Map item = jsonDecode(itemStr); + final String url = item['url'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (url.isEmpty || payload.isEmpty) continue; + + // Determine type of log based on URL or payload structure if needed + // For now, we assume these are mostly createRiderLog calls from createLoginNow + // We can use _createProvider generic call + + debugPrint('[RIDERLOG][OFFLINE] Retrying: $payload'); + final resp = await _createProvider.createRiderLog(url, payload); + + if (resp != null && resp.isNotEmpty) { + debugPrint('[RIDERLOG][OFFLINE] Success!'); + anySuccess = true; + } else { + // Keep in queue if failed + remaining.add(itemStr); + } + } catch (e) { + debugPrint('[RIDERLOG][OFFLINE] Error processing item: $e'); + remaining.add(itemStr); // Keep on error + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + debugPrint( + '[RIDERLOG][OFFLINE] Flush complete. Remaining: ${remaining.length}', + ); + } + } catch (e) { + debugPrint('[RIDERLOG][OFFLINE] Flush error: $e'); + } finally { + _isFlushing = false; + } + } + + Future _saveToOfflineQueue( + String url, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + List queue = prefs.getStringList(_offlineLogKey) ?? []; + + final item = jsonEncode({ + 'url': url, + 'payload': payload, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + + queue.add(item); + + // Cap the queue at 50 entries — keep the newest ones. + // Prevents unbounded growth on devices where the endpoint is unreachable. + const maxQueueSize = 50; + if (queue.length > maxQueueSize) { + queue = queue.sublist(queue.length - maxQueueSize); + debugPrint( + '[RIDERLOG][OFFLINE] Queue capped at $maxQueueSize entries.', + ); + } + + await prefs.setStringList(_offlineLogKey, queue); + debugPrint('[RIDERLOG][OFFLINE] Saved to queue. Total: ${queue.length}'); + } catch (e) { + debugPrint('[RIDERLOG][OFFLINE] Error saving to queue: $e'); + } + } +} + +// 🔹 Helper Adapters +class _CreateRiderLogRequestCompat { + static dynamic fromRiderLogin(RiderLogin login) { + return login.toJson(); + } +} + +class _UpdateRiderLogRequestCompat { + static dynamic fromRiderUpdate(RiderUpdate update) { + return update.toJson(); + } +} + +class _BreakLogRequestCompat { + static dynamic fromRiderBreak(RiderBreak brk) { + return brk.toJson(); + } +} diff --git a/lib/xpress/controllers/summary_controller.dart b/lib/xpress/controllers/summary_controller.dart new file mode 100644 index 0000000..abda36c --- /dev/null +++ b/lib/xpress/controllers/summary_controller.dart @@ -0,0 +1,34 @@ +import 'package:get/get.dart'; +import 'package:miler/xpress/models/summary/deliverystats.dart'; +import 'package:miler/xpress/providers/summary/summary.dart'; + +class SummaryController extends GetxController { + final SummaryProvider _provider = SummaryProvider(); + + // Observables + var today = 0.obs; + var week = 0.obs; + var month = 0.obs; + var total = 0.obs; + var cancelled = 0.obs; + var isLoading = false.obs; + + // Fetch stats and update values + Future fetchSummaryStats(int userId) async { + try { + isLoading.value = true; + final DeliveryStats? stats = await _provider.fetchSummaryStats(userId); + if (stats != null) { + today.value = stats.today; + week.value = stats.week; + month.value = stats.month; + total.value = stats.total; + cancelled.value = stats.cancelled; + } + } catch (e) { + print('Controller error: $e'); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/xpress/controllers/support_ticket.dart b/lib/xpress/controllers/support_ticket.dart new file mode 100644 index 0000000..57bfa71 --- /dev/null +++ b/lib/xpress/controllers/support_ticket.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' show Random; + +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:minio/io.dart'; +import 'package:minio/minio.dart'; +import 'package:miler/xpress/Models/supportticket/support_ticket.dart'; +import 'package:miler/xpress/data/delivery_api.dart'; + +// Minimal local stub for DigitalOcean Spaces client to avoid undefined name errors. +// Replace this with a real package or implementation for production uploads. +class dospace { + static DOSpaceClient DOSpace({ + required String region, + required String accessKey, + required String secretKey, + }) => + DOSpaceClient(region: region, accessKey: accessKey, secretKey: secretKey); + + static final ACL = _ACL(); +} + +class _ACL { + final String publicRead = 'public-read'; +} + +class DOSpaceClient { + final String region; + final String accessKey; + final String secretKey; + + DOSpaceClient({ + required this.region, + required this.accessKey, + required this.secretKey, + }); + + Future putObject({ + required String bucketName, + required String objectName, + required File file, + required String acl, + required String contentType, + }) async { + // No-op stub: implement actual upload logic here or use a proper package. + await Future.value(); + } +} + +class SupportTicketController extends GetxController { + final RxList tickets = [].obs; + final RxBool isLoading = true.obs; + final RxString errorMessage = ''.obs; + final RxBool isSubmitting = false.obs; + + @override + void onInit() { + super.onInit(); + fetchTickets(); + } + + // ---------- FETCH TICKETS ---------- + Future fetchTickets() async { + try { + isLoading(true); + errorMessage(''); + + const userId = 1242; + final url = Uri.parse('${DeliveryApi.getSupportTickets}?userid=$userId'); + + final response = await http.get( + url, + headers: {'Accept': 'application/json'}, + ); + + if (response.statusCode != 200) { + throw Exception('Server error: ${response.statusCode}'); + } + + final Map jsonResponse = json.decode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + final List data = jsonResponse['data']; + tickets.assignAll( + data.map((e) => SupportTicketModel.fromJson(e)).toList(), + ); + } catch (e) { + errorMessage(e.toString()); + } finally { + isLoading(false); + } + } + + // ---------- UPLOAD IMAGE TO DO SPACES ---------- + Future uploadImageToDOSpaces(File imageFile, int userId) async { + try { + final rng = Random(); + const String region = "sgp1"; + const String accessKey = String.fromEnvironment('SPACES_KEY'); + const String secretKey = String.fromEnvironment('SPACES_SECRET'); + const String bucketName = "doormile"; + const String folderName = "support"; + + // File name + final String fileName = 'ticket-${rng.nextInt(10000)}-$userId.jpg'; + + // Object path inside the bucket + final String objectPath = "$folderName/$fileName"; + + // CDN URL you want + final String cdnUrl = DeliveryApi.image(objectPath); + + // Initialize Minio + final minio = Minio( + endPoint: "$region.digitaloceanspaces.com", + accessKey: accessKey, + secretKey: secretKey, + region: region, + useSSL: true, + ); + + print("Uploading: $objectPath"); + + // Upload to DO Spaces + await minio.fPutObject( + bucketName, + objectPath, + imageFile.path, + metadata: {"Content-Type": "image/jpeg", "x-amz-acl": "public-read"}, + ); + + print("Uploaded Successfully: $cdnUrl"); + return cdnUrl; + } catch (e) { + print("Upload error: $e"); + Get.snackbar("Error", "Image upload failed."); + return null; + } + } + + // ---------- CREATE TICKET ---------- + // ---------- CREATE TICKET ---------- + Future createTicket({ + required int userid, + required String category, + required String priority, + required String subject, + required String issue, + List? attachments, + }) async { + try { + isSubmitting(true); + String imageUrl = ""; + + // Upload first image if attached + if (attachments != null && attachments.isNotEmpty) { + final XFile xFile = attachments.first; + final File file = File(xFile.path); + final uploadedUrl = await uploadImageToDOSpaces(file, userid); + + // Only assign if a valid URL (short length) + if (uploadedUrl != null && uploadedUrl.length < 200) { + imageUrl = uploadedUrl; + } else { + print("⚠️ Skipping image URL because it's too long or invalid."); + } + } + + // Create ticket request body + final Map payload = { + 'userid': userid, + 'category': category, + 'priority': priority, + 'subject': subject, + 'issue': issue, + 'image': imageUrl, // ✅ Always short string or empty + }; + + final response = await http.post( + Uri.parse(DeliveryApi.createSupportTicket), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: jsonEncode(payload), + ); + + if (response.statusCode != 200) { + throw Exception('Ticket creation failed: ${response.statusCode}'); + } + + final jsonResponse = jsonDecode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + await fetchTickets(); // Refresh list after success + return true; + } catch (e) { + errorMessage(e.toString()); + return false; + } finally { + isSubmitting(false); + } + } +} diff --git a/lib/xpress/data/delivery_api.dart b/lib/xpress/data/delivery_api.dart new file mode 100644 index 0000000..e4eba13 --- /dev/null +++ b/lib/xpress/data/delivery_api.dart @@ -0,0 +1,191 @@ +import 'package:miler/data/api_config.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// EVERY URL THE DELIVERY LINE CALLS +/// +/// ── Why this file exists ── +/// +/// The delivery screens under `lib/xpress` are a verbatim port of the +/// Xpress-rider app, which talked to a different backend entirely: +/// `jupiter.nearle.app` for reads and `queue.workolik.com` for writes, with the +/// URLs scattered inline across `homepage.dart`, `cartpage.dart`, +/// `deliveries.dart`, `summary.dart` and three controllers. Repointing that at +/// Doormile by find/replacing hostnames is exactly the class of change that +/// produced the `deliver`→`Pick up` corruption this repo has already had to +/// undo once. +/// +/// So every delivery-line URL is named here, once, and the call sites read a +/// getter. Repointing the line is now one file. +/// +/// ── What it points at ── +/// +/// `https://api.doormile.com/api/v1` — the same host and the same bearer token +/// as the parcel line, via [ApiConfig]. There is one Doormile backend and the +/// delivery rider signs in to it with the same credentials as everyone else. +/// +/// ── What is real and what is not ── +/// +/// The Doormile backend (`doormile_backend/routes/routes.go`) does **not** +/// expose a delivery queue. It exposes a miler API built around bookings and +/// consignments. Each constant below is therefore marked: +/// +/// • **LIVE** — a route that exists on the backend today, verified against +/// `routes.go`. Called for real. +/// • **MAPPED** — no exact counterpart, but a route that answers the same +/// question, so the screen has something true to draw. +/// • **ABSENT** — no counterpart at all. The call will 404 until the route is +/// built. Listed rather than hidden, because a rider seeing an +/// empty list needs someone to know why. +/// +/// The ABSENT set is the delivery *queue* — the list of drops a rider is +/// holding. `/miler/assignments` and `/miler/bookings` are the nearest real +/// things and are what [deliveryQueue] and [currentDeliveries] use, but they +/// return the booking shape (a first-mile pickup), not the delivery shape the +/// ported cards read. Until the backend grows a delivery contract, the delivery +/// line will render whatever those return through the existing adapter and will +/// be thin. +/// +/// This is a known, accepted gap: it was chosen deliberately over keeping the +/// working Nearle hosts. See the note in `docs/` if one is added. +/// ───────────────────────────────────────────────────────────────────────── +class DeliveryApi { + DeliveryApi._(); + + /// The one host. Shared with the parcel line — see [ApiConfig.newBase]. + static String url(String path) => ApiConfig.url(path); + + /// Headers for every authenticated delivery call: the same bearer token the + /// parcel line uses, because it is the same rider account on the same server. + static Future> headers() => ApiConfig.authHeaders(); + + // ───────────────────────── the work list ───────────────────────── + + /// **MAPPED** — the drops this rider is holding. + /// + /// Was `/api/v2/deliveries/getdeliveryqueues`. The backend has no queue, so + /// this is the assignment list, which is the same question asked of a + /// different noun: "what has the hub given me?" + static String get deliveryQueue => url('/miler/assignments'); + + /// **MAPPED** — the rider's own accepted work. + /// + /// Was `/api/v2/deliveries/getdeliveries` (and the v1 and v3 date-bounded + /// variants, which collapsed to the same call with different params). + static String get currentDeliveries => url('/miler/bookings'); + + /// **LIVE** — full detail for one assignment. + static String assignmentDetail(Object id) => url('/miler/assignments/$id'); + + /// **LIVE** — accept a job. + /// + /// Keyed on the **bookingassignmentid**, not the bookingid. Passing the wrong + /// one is the first of the two structural breaks recorded against this repo: + /// the handler reads `bookingassignmentid` and the app used to send a + /// bookingid, so every accept silently missed. The id comes from + /// [deliveryQueue]'s rows. + static String accept(Object assignmentId) => + url('/miler/assignments/$assignmentId/accept'); + + /// **LIVE** — decline a job. + static String reject(Object assignmentId) => + url('/miler/assignments/$assignmentId/reject'); + + // ───────────────────────── working a stop ───────────────────────── + + /// **LIVE** — the rider is at the door. + static String reached(Object bookingId) => + url('/miler/bookings/$bookingId/reached'); + + /// **LIVE** — hand the consignment over. The one genuine delivery verb the + /// backend has. + static String deliver(Object consignmentId) => + url('/miler/consignments/$consignmentId/deliver'); + + /// **LIVE** — money changed hands. + static String payment(Object bookingId) => + url('/miler/bookings/$bookingId/payment'); + + /// **ABSENT** — the generic status write the ported flow uses for every + /// transition (accepted → active → arrived → delivered → skipped). + /// + /// Was `queue.workolik.com/live/api/v1/deliveries/updatedelivery`. There is no + /// single status-write route on Doormile; the transitions above are separate + /// endpoints. Call sites that still post a bare status change land here and + /// will 404 until either they are split or the route is built. + static String get updateDelivery => url('/miler/bookings/status'); + + // ───────────────────────── rider logs and duty ───────────────────────── + + /// **LIVE** — periodic location/telemetry log. + /// + /// Was `queue.workolik.com/live/api/v2/partners/createriderlog`. + static String get createRiderLog => url('/miler/logs'); + + /// **LIVE** — read them back. + static String get getRiderLog => url('/miler/logs'); + + /// **LIVE** — on/off duty. + static String get startDuty => url('/miler/duty/start'); + static String get endDuty => url('/miler/duty/end'); + static String get currentDuty => url('/miler/duty/current'); + + /// **LIVE** — break start/end. Was the `createbreaklog`/`updatebreaklog` + /// pair on the queue host. + static String get startBreak => url('/miler/breaks/start'); + static String get endBreak => url('/miler/breaks/end'); + + /// **LIVE** — availability toggle, which is what the duty slider on the + /// delivery home screen actually means to the server. + static String get availability => url('/miler/availability'); + + /// **LIVE** — live location ping. + static String get location => url('/miler/location'); + + /// **ABSENT** — per-delivery event log. + /// + /// Was `queue.workolik.com/live/api/v2/deliveries/createdeliverylog`. The + /// closest real thing is consignment logs, which key on a consignmentid the + /// delivery flow does not always hold. + static String get createDeliveryLog => url('/miler/consignments/logs'); + + // ───────────────────────── earnings and profile ───────────────────────── + + /// **LIVE** — the Summary tab's figures. Was `/api/v2/partners/...`. + static String get earnings => url('/miler/earnings'); + + /// **ABSENT** — the weekly kilometre chart on Summary. + /// + /// Was `/api/v1/partners/getriderweeklykms`. Doormile tracks distance through + /// the periodic logs but exposes no aggregate, so the chart has no source. + static String get weeklyKms => url('/miler/earnings/weekly-kms'); + + /// **ABSENT** — the bonus/rewards summary behind the rewards card. + static String get bonusSummary => url('/miler/earnings/bonus'); + + /// **LIVE** — rider profile. + static String get profile => url('/miler/profile'); + + // ───────────────────────── support ───────────────────────── + + /// **LIVE** — raise and list support tickets. + static String get createSupportTicket => url('/miler/support'); + static String get getSupportTickets => url('/miler/support'); + + /// **ABSENT** — image upload for a support ticket attachment. + /// + /// Was `/api/v1/partners/uploadimage/`, with the result served back from + /// `images.nearle.app`. Doormile has no upload route and no image host, so + /// attachments cannot be sent. + static String get uploadImage => url('/miler/support/upload'); + + /// Public base for an uploaded image. **ABSENT** — see [uploadImage]. + static String image(String objectPath) => + 'https://images.doormile.com/$objectPath'; + + // ───────────────────────── notifications ───────────────────────── + + /// **LIVE** + static String get notifications => url('/miler/notifications'); + static String markNotificationRead(Object id) => + url('/miler/notifications/$id/read'); +} diff --git a/lib/xpress/delivery_bootstrap.dart b/lib/xpress/delivery_bootstrap.dart new file mode 100644 index 0000000..35bdcff --- /dev/null +++ b/lib/xpress/delivery_bootstrap.dart @@ -0,0 +1,103 @@ +import 'package:get/get.dart'; + +import 'package:miler/xpress/controllers/delivery.dart'; +import 'package:miler/xpress/controllers/logcontroller.dart'; +import 'package:miler/xpress/controllers/profile_controller.dart'; +import 'package:miler/xpress/controllers/riderlog.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE DELIVERY LINE'S CONTROLLERS +/// +/// ── The problem this solves ── +/// +/// `main()` registers the parcel line's controllers — `RiderLogController`, +/// `PickupController`, `LogController`, `ProfileController` — and the ported +/// delivery screens ask for controllers with three of those *same names*. +/// +/// They are not the same classes. `miler/controllers/riderlog.dart` and +/// `miler/xpress/controllers/riderlog.dart` both declare a `RiderLogController`, +/// and GetX keys its container on the **type**, not the name. So a delivery +/// screen calling `Get.find()` — resolving to the xpress +/// type — finds nothing, no matter that a controller by that name is registered. +/// +/// That failure is not graceful. The delivery home page resolves its controller +/// in a *field initialiser*: +/// +/// final DeliveryController _deliveryController = Get.find(); +/// +/// which runs while the widget is being constructed, so an unregistered +/// controller throws before the first frame of the delivery dashboard and the +/// rider gets a red screen rather than a degraded one. +/// +/// ── Why it is not simply added to `main()` ── +/// +/// It is called from `main()`, but only for a rider on the delivery line. A +/// parcel rider must not pay for four controllers he will never look at — two of +/// which (`RiderLogController`, `LogController`) start timers and location work +/// on construction. Registering both lines' logging controllers at once would +/// have a single rider filing two sets of duty logs against two different +/// endpoints. +/// +/// So: one line's controllers, chosen once, at boot. +/// +/// ── Idempotent on purpose ── +/// +/// Guarded by `isRegistered` rather than assuming a clean container, because +/// this is also called defensively from [riderShell] — a rider who signs out of +/// a parcel account and into a delivery one switches lines without the process +/// restarting, so boot is not the only moment this can be needed. +/// ───────────────────────────────────────────────────────────────────────── +/// Puts every delivery-line controller in the container. **Synchronous, and it +/// must stay that way.** +/// +/// ── The bug this shape exists to prevent ── +/// +/// This was once a single `async` function that awaited `loadFromPrefs()` before +/// registering `DeliveryController`. Called from [riderShell] without an await — +/// correctly, since a widget builder cannot await — the first `await` handed +/// control back to the framework, which built the delivery shell *during the +/// suspension*, while three of the four controllers were still unregistered: +/// +/// "DeliveryController" not found. You need to call +/// "Get.put(DeliveryController())" +/// +/// thrown from the home page's field initialiser, i.e. a red screen instead of a +/// dashboard. A registration that a synchronous caller depends on cannot sit +/// behind an await. +/// +/// So registration is synchronous and complete when it returns, and anything +/// that needs IO is [warmDeliveryLine]'s job. +void registerDeliveryLine() { + if (!Get.isRegistered()) { + Get.put(ProfileController(), permanent: true); + } + if (!Get.isRegistered()) { + Get.put(DeliveryController(), permanent: true); + } + if (!Get.isRegistered()) { + Get.put(RiderLogController(), permanent: true); + } + if (!Get.isRegistered()) { + Get.put(LogController(), permanent: true); + } +} + +/// Registers the line, then does the IO that wants to be finished before the +/// first frame. Awaited from `main()`; see [registerDeliveryLine] for why the +/// two halves are separate. +Future bootstrapDeliveryLine() async { + final firstRun = !Get.isRegistered(); + + registerDeliveryLine(); + + // The rider's photo and name: the delivery home page's header draws the + // avatar, so populating this late means the letter fallback flashes on every + // launch. Mirrors what `main()` does for the parcel line. + await Get.find().loadFromPrefs(); + + if (firstRun) { + // Same call `main()` makes for parcel. It no-ops unless the rider is + // already on duty — see the `onduty` guard inside `startLogging`. + await Get.find().startLogging(); + } +} diff --git a/lib/xpress/helpers/shift_end_alarm.dart b/lib/xpress/helpers/shift_end_alarm.dart new file mode 100644 index 0000000..9405a3e --- /dev/null +++ b/lib/xpress/helpers/shift_end_alarm.dart @@ -0,0 +1,144 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/background/backgroundservice.dart'; + +/// Helper class to schedule/cancel shift end alarms +/// Works even when app is killed (uses Android AlarmManager) +class ShiftEndAlarm { + static const MethodChannel _channel = MethodChannel('doormile/shift_end'); + + /// Schedule an alarm for shift end time + /// This alarm will fire even if the app is killed + /// If shift end time has already passed today, it will trigger immediately + static Future scheduleAlarm({ + required String endTime, // Format: "HH:mm:ss" or "HH:mm" + String startTime = + '', // Format: "HH:mm:ss" or "HH:mm" (for overnight shift detection) + }) async { + if (!Platform.isAndroid) { + debugPrint('[SHIFT_END_ALARM] Only supported on Android'); + return false; + } + + try { + debugPrint( + '[SHIFT_END_ALARM] 📅 scheduleAlarm called - endTime: "$endTime", startTime: "$startTime"', + ); + + // Check if shift end time has already passed today + final now = DateTime.now(); + debugPrint('[SHIFT_END_ALARM] 📅 Current time: ${now.toString()}'); + + final endParts = endTime.split(':'); + if (endParts.length >= 2) { + final int endHour = int.tryParse(endParts[0]) ?? 0; + final int endMinute = int.tryParse(endParts[1]) ?? 0; + final int endSecond = endParts.length > 2 + ? (int.tryParse(endParts[2]) ?? 0) + : 0; + + final DateTime endToday = DateTime( + now.year, + now.month, + now.day, + endHour, + endMinute, + endSecond, + ); + + debugPrint( + '[SHIFT_END_ALARM] 📅 Shift end time today: ${endToday.toString()}', + ); + debugPrint( + '[SHIFT_END_ALARM] 📅 Time comparison: now.isAfter(endToday) = ${now.isAfter(endToday)}', + ); + + // If shift end time has passed, trigger immediately + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + debugPrint( + '[SHIFT_END_ALARM] ⚡ Shift end time ($endTime) has already passed - triggering break log immediately', + ); + await handleShiftEnd(); + } else { + debugPrint( + '[SHIFT_END_ALARM] ⏰ Shift end time ($endTime) has not passed yet - will schedule alarm', + ); + } + } else { + debugPrint('[SHIFT_END_ALARM] ⚠️ Invalid endTime format: "$endTime"'); + } + + final result = await _channel.invokeMethod( + 'scheduleShiftEndAlarm', + {'endTime': endTime, 'startTime': startTime}, + ); + final success = result ?? false; + if (success) { + debugPrint( + '[SHIFT_END_ALARM] ✅ Successfully scheduled alarm for shift end: $endTime', + ); + } else { + debugPrint( + '[SHIFT_END_ALARM] ⚠️ Failed to schedule alarm for shift end: $endTime', + ); + } + return success; + } on PlatformException catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Error scheduling alarm: ${e.message}'); + return false; + } catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Unexpected error: $e'); + return false; + } + } + + /// Cancel the scheduled shift end alarm + static Future cancelAlarm() async { + if (!Platform.isAndroid) { + return false; + } + + try { + final result = await _channel.invokeMethod('cancelShiftEndAlarm'); + debugPrint('[SHIFT_END_ALARM] ✅ Cancelled shift end alarm'); + return result ?? false; + } on PlatformException catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Error cancelling alarm: ${e.message}'); + return false; + } catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Unexpected error: $e'); + return false; + } + } + + /// Handle shift end when alarm fires (called by BroadcastReceiver) + /// This will create the break log and set rider offline + static Future handleShiftEnd() async { + try { + debugPrint( + '[SHIFT_END_ALARM] 🔔 Shift end alarm fired - creating break log...', + ); + + final prefs = await SharedPreferences.getInstance(); + + // Check if still on duty (might have been manually set offline) + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint( + '[SHIFT_END_ALARM] Already offline, skipping break log creation', + ); + return; + } + + // Call the background service to create break log + // This will create break log and set offline + await BackgroundDeliveryLog.checkShiftEnd(); + + debugPrint('[SHIFT_END_ALARM] ✅ Break log created successfully'); + } catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Error handling shift end: $e'); + } + } +} diff --git a/lib/xpress/providers/Riderlog/riderlog_provider.dart b/lib/xpress/providers/Riderlog/riderlog_provider.dart new file mode 100644 index 0000000..81182e5 --- /dev/null +++ b/lib/xpress/providers/Riderlog/riderlog_provider.dart @@ -0,0 +1,260 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:flutter/foundation.dart'; + +// Combined Riderlog providers: + +/// Hardcoded known-good IPs for hosts where carrier DNS returns broken nodes. +/// +/// **Empty since the delivery line moved to Doormile.** The one entry here +/// pinned `queue.workolik.com` to 66.116.225.226, because some Indian carriers' +/// DNS resolved that host to a CDN node that answered 404. Nothing calls that +/// host any more — every delivery-line write goes to `api.doormile.com` via +/// [DeliveryApi] — so the pin was routing advice about a server this app no +/// longer talks to. +/// +/// It is deliberately not re-pointed at the Doormile host: that IP belongs to +/// the old backend, and pinning it for `api.doormile.com` would send every +/// request to the wrong server with the right SNI. If Doormile ever needs the +/// same workaround, add it here with its own verified address. +const _knownGoodIPs = {}; + +/// Creates an IOClient that: +/// 1. Bypasses SSL certificate errors +/// 2. Forces known-good IPs for hosts where carrier DNS returns broken CDN nodes +/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) +IOClient _buildSslBypassClient() { + final httpClient = HttpClient() + ..badCertificateCallback = (X509Certificate cert, String host, int port) => + true; + + httpClient + .connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) async { + final host = uri.host; + final port = uri.port; + + // Use known-good IP if available, else resolve normally (prefer IPv4) + InternetAddress? target; + final knownIP = _knownGoodIPs[host]; + if (knownIP != null) { + target = InternetAddress(knownIP); + debugPrint('[SSL_CLIENT] Using known-good IP: $knownIP for $host'); + } else { + try { + final addresses = await InternetAddress.lookup( + host, + type: InternetAddressType.IPv4, + ); + if (addresses.isNotEmpty) target = addresses.first; + } catch (_) {} + } + + if (uri.scheme == 'https') { + final socketFuture = Socket.connect(target ?? InternetAddress(host), port) + .then( + (plain) => SecureSocket.secure( + plain, + host: host, // SNI = original hostname for Nginx routing + onBadCertificate: (_) => true, + supportedProtocols: ['http/1.1'], + ), + ) + .then((s) => s as Socket); + return ConnectionTask.fromSocket(socketFuture, () {}); + } + + return Socket.startConnect(target ?? InternetAddress(host), port); + }; + + return IOClient(httpClient); +} + +class CreateRiderLogProvider { + Future?> createRiderLog( + String urldata, + Map data, + ) async { + const maxAttempts = 3; + try { + debugPrint('createRiderLog payload ${json.encode(data)}'); + } catch (_) {} + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.post( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('createRiderLog url $urldata (attempt $attempt)'); + debugPrint('createRiderLog status ${response.statusCode}'); + debugPrint('createRiderLog response ${response.body}'); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return json.decode(response.body.toString()) as Map; + } else { + debugPrint( + 'createRiderLog failed: HTTP ${response.statusCode} (attempt $attempt/$maxAttempts)', + ); + // On 404/5xx, wait and retry to potentially hit a different CDN node + if (attempt < maxAttempts) { + await Future.delayed(const Duration(seconds: 1)); + } + } + } catch (e) { + debugPrint('createRiderLog exception (attempt $attempt): $e'); + if (attempt < maxAttempts) { + await Future.delayed(const Duration(seconds: 1)); + } + } finally { + client.close(); + } + } + + debugPrint('createRiderLog failed after $maxAttempts attempts'); + return null; + } +} + +class UpdateRiderLogProvider { + Future?> updateRiderLog( + String urldata, + Map data, + ) async { + try { + final url = Uri.parse(urldata); + final response = await put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + + debugPrint('updateRiderLog url: $urldata'); + debugPrint('updateRiderLog response: ${response.body}'); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + if (decoded is Map) { + return decoded; + } else { + debugPrint( + '⚠️ updateRiderLog: Expected Map but got ${decoded.runtimeType}', + ); + return {}; + } + } else { + debugPrint('❌ updateRiderLog failed with code ${response.statusCode}'); + return {}; + } + } catch (e) { + debugPrint('❌ Exception in updateRiderLog: $e'); + return {}; + } + } +} + +class GetRiderLogProvider { + Future?> getRiderLog(String urldata) async { + Map? getRiderLogResponse; + try { + final url = Uri.parse(urldata); + final response = await get( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('getRiderLog response ${response.body}'); + debugPrint('getRiderLog url ${urldata.toString()}'); + getRiderLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return getRiderLogResponse; + } + + Future?> getRiderCount(String urldata) async { + Map? getRiderCountResponse; + try { + final url = Uri.parse(urldata); + final response = await get( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('getRiderCount response ${response.body}'); + debugPrint('getRiderCount url ${urldata.toString()}'); + getRiderCountResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return getRiderCountResponse; + } +} + +class BreakRiderLogProvider { + Future?> createBreakRiderLog( + String urldata, + Map data, + ) async { + Map? breakLogResponse; + try { + final url = Uri.parse(urldata); + final response = await post( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('createBreakRiderLog url $urldata'); + debugPrint('createBreakRiderLog response ${response.body}'); + breakLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return breakLogResponse; + } + + Future?> updateBreakRiderLog( + String urldata, + Map data, + ) async { + Map? breakLogResponse; + try { + final url = Uri.parse(urldata); + final response = await put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('updateBreakRiderLog url $urldata'); + debugPrint('updateBreakRiderLog response ${response.body}'); + breakLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return breakLogResponse; + } +} diff --git a/lib/xpress/providers/delivery/delivery_provider.dart b/lib/xpress/providers/delivery/delivery_provider.dart new file mode 100644 index 0000000..05858e8 --- /dev/null +++ b/lib/xpress/providers/delivery/delivery_provider.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; + +class DeliveryProvider { + final http.Client _client; + + DeliveryProvider({http.Client? client}) : _client = client ?? http.Client(); + + Future _getWithRetry(Uri uri, {int maxAttempts = 3}) async { + int attempt = 0; + while (true) { + attempt++; + try { + return await _client.get(uri); + } on SocketException catch (_) { + if (attempt >= maxAttempts) rethrow; + } on http.ClientException catch (_) { + if (attempt >= maxAttempts) rethrow; + } + // Exponential backoff: 300ms, 600ms + final delayMs = 300 * attempt; + await Future.delayed(Duration(milliseconds: delayMs)); + } + } + + Future> getDeliveryQueues({ + required bool live, + required int userid, + String? orderstatus, + }) async { + final base = live + ? ApiConstants.deliveryQueueLive + : ApiConstants.deliveryQueueDev; + + // Get current date in YYYY-MM-DD format + final now = DateTime.now(); + final mm = now.month.toString().padLeft(2, '0'); + final dd = now.day.toString().padLeft(2, '0'); + final yyyy = now.year.toString(); + final today = "$yyyy-$mm-$dd"; + + final qp = { + 'userid': userid.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }; + if (orderstatus != null && orderstatus.isNotEmpty) { + qp['orderstatus'] = orderstatus; + } + final uri = Uri.parse(base).replace(queryParameters: qp); + + final res = await _getWithRetry(uri); + + if (res.statusCode >= 200 && res.statusCode < 300) { + // Log URL and raw status + // ignore: avoid_print + print('[DELIVERIES][GET] URL: ${uri.toString()}'); + final decoded = json.decode(res.body); + + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + if (data is List) { + try { + // Pretty log the list if deliveries are found + if (data.isNotEmpty) { + // ignore: avoid_print + // print('[DELIVERIES][GET] Data: ${json.encode(data)}'); // Heavy log, disabled for performance + } else { + // ignore: avoid_print + // print('[DELIVERIES][GET] Data: []'); + } + } catch (_) {} + return data; + } + if (data is Map && data['items'] is List) return data['items'] as List; + + return []; + } + + throw Exception('Failed (${res.statusCode})'); + } + + Future> getDeliveryQueuesPicked({ + required bool live, + required int userid, + }) async { + // Use v3 getdeliveries with fromdate/todate as today's date (dynamic) + final base = live + ? ApiConstants.currentDeliveryV3Live + : ApiConstants.currentDeliveryV3Dev; + // Get current date in YYYY-MM-DD format (dynamically updated each day) + final now = DateTime.now(); + final mm = now.month.toString().padLeft(2, '0'); + final dd = now.day.toString().padLeft(2, '0'); + final yyyy = now.year.toString(); + final today = "$yyyy-$mm-$dd"; + + final qp = { + 'userid': userid.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }; + final uri = Uri.parse(base).replace(queryParameters: qp); + + final res = await _getWithRetry(uri); + + if (res.statusCode >= 200 && res.statusCode < 300) { + // ignore: avoid_print + print('[DELIVERIES][GET_PICKED] URL: ${uri.toString()}'); + final decoded = json.decode(res.body); + + // The API returns {"code":200,"details":[...],"message":"Success","status":true} + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + if (data is List) { + try { + if (data.isNotEmpty) { + // ignore: avoid_print + // print('[DELIVERIES][GET_PICKED] Data: ${json.encode(data)}'); // Heavy log, disabled + } else { + // ignore: avoid_print + // print('[DELIVERIES][GET_PICKED] Data: []'); + } + } catch (_) {} + return data; + } + if (data is Map && data['items'] is List) return data['items'] as List; + + return []; + } + + throw Exception('Failed (${res.statusCode})'); + } +} diff --git a/lib/xpress/providers/deliverylog/deliverylog_provider.dart b/lib/xpress/providers/deliverylog/deliverylog_provider.dart new file mode 100644 index 0000000..ab886d4 --- /dev/null +++ b/lib/xpress/providers/deliverylog/deliverylog_provider.dart @@ -0,0 +1,283 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:flutter/foundation.dart'; + +// Combined Deliverylog providers: + +/// Hardcoded known-good IPs for hosts where carrier DNS returns broken CDN nodes. +/// Confirmed: 66.116.225.226 = 200 OK, 125.21.240.67 = 404/405. +/// Empty since the delivery line moved to Doormile — see the note on the +/// matching map in `providers/Riderlog/riderlog_provider.dart`. The old entry +/// pinned `queue.workolik.com`, a host nothing calls now, and must not be +/// re-pointed at `api.doormile.com`. +const _knownGoodIPs = {}; + +/// Creates an IOClient that: +/// 1. Bypasses SSL certificate errors +/// 2. Forces known-good IPs to avoid broken CDN nodes from carrier DNS +/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) +IOClient _buildSslBypassClient() { + final httpClient = HttpClient() + ..badCertificateCallback = (X509Certificate cert, String host, int port) => + true; + + httpClient + .connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) async { + final host = uri.host; + final port = uri.port; + + InternetAddress? target; + final knownIP = _knownGoodIPs[host]; + if (knownIP != null) { + target = InternetAddress(knownIP); + } else { + try { + final addresses = await InternetAddress.lookup( + host, + type: InternetAddressType.IPv4, + ); + if (addresses.isNotEmpty) target = addresses.first; + } catch (_) {} + } + + if (uri.scheme == 'https') { + final socketFuture = Socket.connect(target ?? InternetAddress(host), port) + .then( + (plain) => SecureSocket.secure( + plain, + host: host, + onBadCertificate: (_) => true, + supportedProtocols: ['http/1.1'], + ), + ) + .then((s) => s as Socket); + return ConnectionTask.fromSocket(socketFuture, () {}); + } + + return Socket.startConnect(target ?? InternetAddress(host), port); + }; + + return IOClient(httpClient); +} + +class CreateDeliveryLogProvider { + Future?> createDeliveryLog( + String urldata, + Map data, { + bool wrapInArray = true, + }) async { + Map? result; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final body = json.encode(wrapInArray ? [data] : data); + final response = await client + .post( + url, + body: body, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ) + .timeout(const Duration(seconds: 10)); + debugPrint('createDeliveryLog url $urldata'); + debugPrint(body); + debugPrint('createDeliveryLog response ${response.body}'); + if (response.statusCode >= 200 && response.statusCode < 300) { + result = json.decode(response.body.toString()) as Map; + debugPrint('createDeliveryLog parsed ${result.toString()}'); + } else { + debugPrint('createDeliveryLog failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('createDeliveryLog timeout: $e'); + } catch (e) { + debugPrint('createDeliveryLog error: $e'); + } finally { + client.close(); + } + return result; + } +} + +class UpdateDeliveryProvider { + Future?> updateDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client + .put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ) + .timeout(const Duration(seconds: 10)); + debugPrint('updateDelivery url $urldata'); + debugPrint('updateDelivery status ${response.statusCode}'); + debugPrint('updateDelivery response ${response.body}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updateDelivery failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updateDelivery timeout: $e'); + } catch (e) { + debugPrint('updateDelivery error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } + + Future?> updateArrivedDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client + .put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ) + .timeout(const Duration(seconds: 10)); + debugPrint('updateArrived url $urldata'); + debugPrint('updateArrived status ${response.statusCode}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updateArrived failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updateArrived timeout: $e'); + } catch (e) { + debugPrint('updateArrived error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } + + Future?> updatePickedDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client + .put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ) + .timeout(const Duration(seconds: 10)); + debugPrint('updatePicked url $urldata'); + debugPrint('updatePicked status ${response.statusCode}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updatePicked failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updatePicked timeout: $e'); + } catch (e) { + debugPrint('updatePicked error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } + + Future?> updateActiveDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client + .put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ) + .timeout(const Duration(seconds: 10)); + debugPrint('updateActive url $urldata'); + debugPrint('updateActive status ${response.statusCode}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updateActive failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updateActive timeout: $e'); + } catch (e) { + debugPrint('updateActive error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } +} + +class GetDeliveryLogProvider { + Future?> getDeliveryLog(String urldata) async { + Map? result; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.get( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('getDeliveryLog url $urldata'); + debugPrint('getDeliveryLog response ${response.body}'); + if (response.statusCode >= 200 && response.statusCode < 300) { + result = json.decode(response.body.toString()) as Map; + } + } catch (e) { + debugPrint('getDeliveryLog error: $e'); + } finally { + client.close(); + } + return result; + } +} diff --git a/lib/xpress/providers/notifications/notificationservce.dart b/lib/xpress/providers/notifications/notificationservce.dart new file mode 100644 index 0000000..9575c2f --- /dev/null +++ b/lib/xpress/providers/notifications/notificationservce.dart @@ -0,0 +1,410 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:miler/helpers/http_overrides.dart'; + +// Top-level background handler required by Firebase Messaging +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + HttpOverrides.global = MyHttpOverrides(); + try { + await Firebase.initializeApp(); + } catch (_) {} + + await NotificationServce.display(message); +} + +class NotificationServce { + static final FirebaseMessaging _firebaseMessaging = + FirebaseMessaging.instance; + static final FlutterLocalNotificationsPlugin _notificationsPlugin = + FlutterLocalNotificationsPlugin(); + static final AudioPlayer _player = AudioPlayer(); + static String _channelId = 'Doormile'; + static bool _isPlaying = false; + + static const AndroidNotificationChannel channel = AndroidNotificationChannel( + 'Doormile', + 'Doormile Notification', + description: 'Channel for Doormile notifications', + importance: Importance.max, + playSound: true, + enableVibration: true, + showBadge: true, + ); + + static Future initialize(BuildContext context) async { + try { + final prefs = await SharedPreferences.getInstance(); + final alreadyInit = prefs.getBool('notifications_init_done') ?? false; + if (alreadyInit) { + return; + } + + await FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + final existing = (prefs.getString('order_alert_sound') ?? '').trim(); + if (existing.isEmpty) { + await prefs.setString('order_alert_sound', 'assets/audio/alert-1.mp3'); + } + await prefs.setBool('notifications_init_done', true); + } catch (_) {} + + await _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(channel); + + await _applyChannelSoundFromPrefs(); + + const InitializationSettings initializationSettings = + InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestSoundPermission: true, + requestBadgePermission: true, + requestAlertPermission: true, + defaultPresentSound: true, + defaultPresentBadge: true, + defaultPresentBanner: true, + defaultPresentAlert: true, + defaultPresentList: true, + ), + ); + + await _notificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: + (NotificationResponse response) async {}, + ); + + RemoteMessage? initialMessage = await _firebaseMessaging + .getInitialMessage(); + if (initialMessage != null) { + await _handleInitialMessage(initialMessage); + } + + FirebaseMessaging.onMessage.listen((RemoteMessage message) async { + await _handleMessage(message); + }); + + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) async { + await _handleMessageOpenedApp(message); + }); + } + + // ✅ Removed duplicated background handler (this was breaking your notifications) + + static Future _handleInitialMessage(RemoteMessage message) async { + if (message.notification != null) { + await display(message); + } + } + + static Future _handleMessage(RemoteMessage message) async { + if (message.notification != null) { + await display(message); + } + } + + static Future _handleMessageOpenedApp(RemoteMessage message) async {} + + static Future _applyChannelSoundFromPrefs() async { + try { + final prefs = await SharedPreferences.getInstance(); + String sel = (prefs.getString('order_alert_sound') ?? '').trim(); + if (sel.isEmpty) { + sel = 'assets/audio/alert-1.mp3'; + } + + final fileName = sel.split('/').last; + final base = fileName.split('.').first; + final rawName = base.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); + + final androidImpl = _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + + if (androidImpl != null) { + _channelId = 'Doormile_$rawName'; + + final custom = AndroidNotificationChannel( + _channelId, + 'Doormile Notification', + description: 'Channel for Doormile notifications', + importance: Importance.max, + playSound: true, + sound: RawResourceAndroidNotificationSound(rawName), + enableVibration: true, + showBadge: true, + ); + + await androidImpl.createNotificationChannel(custom); + } + } catch (_) {} + } + + static Future _downloadAndSaveImage( + String imageUrl, + String fileName, + ) async { + try { + final directory = await getTemporaryDirectory(); + final filePath = '${directory.path}/$fileName'; + final response = await http.get(Uri.parse(imageUrl)); + if (response.statusCode == 200) { + final file = File(filePath); + await file.writeAsBytes(response.bodyBytes); + return filePath; + } + return null; + } catch (_) { + return null; + } + } + + static String? _extractImageUrl(RemoteMessage message) { + String? imageUrl = message.data['image'] as String?; + imageUrl ??= message.notification?.android?.imageUrl; + imageUrl ??= message.notification?.apple?.imageUrl; + return imageUrl; + } + + static Future _playSelectedSound({int times = 1}) async { + try { + if (_isPlaying) return; + + _isPlaying = true; + + final prefs = await SharedPreferences.getInstance(); + String selected = (prefs.getString('order_alert_sound') ?? '').trim(); + if (selected.isEmpty) { + selected = 'assets/audio/alert-1.mp3'; + } + + final rel = selected.startsWith('assets/') + ? selected.replaceFirst('assets/', '') + : selected; + + await _player.stop(); + await _player.setReleaseMode(ReleaseMode.stop); + + for (int i = 0; i < times; i++) { + await _player.play(AssetSource(rel)); + try { + await _player.onPlayerComplete.first; + } catch (_) {} + if (i < times - 1) { + await Future.delayed(const Duration(milliseconds: 120)); + } + } + } catch (_) { + } finally { + _isPlaying = false; + } + } + + /// Lightweight local notification helper for in-app events (no FCM message). + static Future showLocalNotification({ + required String title, + required String body, + bool playSound = true, + String? payload, + }) async { + try { + final id = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Doormile Notification', + importance: Importance.max, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + playSound: playSound, + enableVibration: true, + channelShowBadge: true, + ongoing: false, + autoCancel: true, + ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: playSound, + presentList: true, + presentBanner: true, + ), + ); + + await _notificationsPlugin.show( + id, + title, + body, + notificationDetails, + payload: payload, + ); + } catch (_) {} + } + + static Future display(RemoteMessage message) async { + final id = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final payload = jsonEncode({'id': id.toString(), 'data': message.data}); + + final appState = WidgetsBinding.instance.lifecycleState; + final bool isForeground = appState == AppLifecycleState.resumed; + + if (isForeground) { + await _playSelectedSound(times: 5); + } + + NotificationDetails notificationDetails; + + final imageUrl = _extractImageUrl(message); + String? persistedImageUrl = imageUrl; + String? persistedImagePath; + + if (imageUrl != null && imageUrl.isNotEmpty) { + final imagePath = await _downloadAndSaveImage( + imageUrl, + 'notification_image.jpg', + ); + persistedImagePath = imagePath; + + notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Doormile Notification', + importance: Importance.max, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + playSound: !isForeground, + enableVibration: true, + fullScreenIntent: true, + channelShowBadge: true, + ongoing: false, + autoCancel: true, + styleInformation: imagePath != null + ? BigPictureStyleInformation(FilePathAndroidBitmap(imagePath)) + : const DefaultStyleInformation(true, true), + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + presentList: true, + presentBanner: true, + ), + ); + } else { + notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Doormile Notification', + importance: Importance.max, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + playSound: !isForeground, + enableVibration: true, + fullScreenIntent: true, + channelShowBadge: true, + ongoing: false, + autoCancel: true, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + presentList: true, + presentBanner: true, + ), + ); + } + + if (isForeground) { + final ctx = Get.context; + if (ctx != null) { + final title = + message.notification?.title ?? message.data['title'] ?? 'Doormile'; + final body = message.notification?.body ?? message.data['body'] ?? ''; + + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + if (body.isNotEmpty) Text(body), + ], + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + duration: const Duration(seconds: 3), + ), + ); + } + } else { + await _notificationsPlugin.show( + id, + message.notification?.title ?? message.data['title'] ?? 'Doormile', + message.notification?.body ?? message.data['body'] ?? 'Notification', + notificationDetails, + payload: payload, + ); + } + + try { + final prefs = await SharedPreferences.getInstance(); + final nowIso = DateTime.now().toIso8601String(); + + final title = + message.notification?.title ?? message.data['title'] ?? 'Doormile'; + final body = message.notification?.body ?? message.data['body'] ?? ''; + + final entry = { + 'id': id, + 'title': title, + 'body': body, + 'time': nowIso, + 'data': message.data, + if (persistedImageUrl != null) 'imageUrl': persistedImageUrl, + if (persistedImagePath != null) 'imagePath': persistedImagePath, + }; + + final existingRaw = prefs.getString('notifications_log'); + List list = []; + + if (existingRaw != null && existingRaw.isNotEmpty) { + try { + list = jsonDecode(existingRaw) as List; + } catch (_) {} + } + + list.insert(0, entry); + + if (list.length > 100) list = list.sublist(0, 100); + + await prefs.setString('notifications_log', jsonEncode(list)); + } catch (_) {} + } +} diff --git a/lib/xpress/providers/summary/riderweeklykms.dart b/lib/xpress/providers/summary/riderweeklykms.dart new file mode 100644 index 0000000..59ac28b --- /dev/null +++ b/lib/xpress/providers/summary/riderweeklykms.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:miler/xpress/Models/summary/riderweeklykms.dart'; +import 'package:miler/xpress/controllers/riderkm.dart'; + +class RiderWeeklyKmProvider extends ChangeNotifier { + final RiderWeeklyKmController _controller = RiderWeeklyKmController(); + + bool isLoading = false; + String? error; + List kmsList = []; + double totalKms = 0.0; + + Future fetchRiderWeeklyKms(int userId) async { + isLoading = true; + error = null; + notifyListeners(); + + try { + final result = await _controller.getRiderWeeklyKms(userId); + kmsList = result['details']; + totalKms = result['total_kms']; + } catch (e) { + error = e.toString(); + } + + isLoading = false; + notifyListeners(); + } +} diff --git a/lib/xpress/providers/summary/summary.dart b/lib/xpress/providers/summary/summary.dart new file mode 100644 index 0000000..5df4cec --- /dev/null +++ b/lib/xpress/providers/summary/summary.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:miler/xpress/models/summary/deliverystats.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; + +class SummaryProvider { + final String baseUrl = ApiConstants.summaryApiLive; + + Future fetchSummaryStats(int userId) async { + final url = Uri.parse('$baseUrl/getdeliverystats?userid=$userId'); + + try { + print(url); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded['status'] == true && decoded['data'] != null) { + return DeliveryStats.fromJson(decoded['data']); + } else { + print('API returned false status: ${decoded['message']}'); + } + } else { + print('something went wrong'); + } + } catch (e) { + print('something went wrong'); + } + return null; + } +} diff --git a/lib/xpress/providers/support/support_ticket.dart b/lib/xpress/providers/support/support_ticket.dart new file mode 100644 index 0000000..be4650e --- /dev/null +++ b/lib/xpress/providers/support/support_ticket.dart @@ -0,0 +1,137 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:get/get_rx/src/rx_types/rx_types.dart'; +import 'package:get/get_state_manager/src/simple/get_controllers.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:miler/xpress/Models/supportticket/support_ticket.dart'; +import 'package:miler/xpress/data/delivery_api.dart'; + +class SupportTicketController extends GetxController { + final RxList tickets = [].obs; + final RxBool isLoading = true.obs; + final RxString errorMessage = ''.obs; + final RxBool isSubmitting = false.obs; + + @override + void onInit() { + super.onInit(); + fetchTickets(); + } + + Future fetchTickets() async { + try { + isLoading(true); + errorMessage(''); + + const userId = 1242; + final url = Uri.parse('${DeliveryApi.getSupportTickets}?userid=$userId'); + + final response = await http.get( + url, + headers: {'Accept': 'application/json'}, + ); + + if (response.statusCode != 200) { + throw Exception('Server error: ${response.statusCode}'); + } + + final Map jsonResponse = json.decode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + final List data = jsonResponse['data']; + tickets.assignAll( + data.map((e) => SupportTicketModel.fromJson(e)).toList(), + ); + } catch (e) { + errorMessage(e.toString()); + } finally { + isLoading(false); + } + } + + Future createTicket({ + required int userid, + required String category, + required String priority, + required String subject, + required String issue, + List? attachments, + }) async { + try { + isSubmitting(true); + + // Step 1: Upload image if attached (adjust endpoint if needed) + String? imageUrl; + if (attachments != null && attachments.isNotEmpty) { + // For simplicity, assume first image; upload to a temp endpoint or your main one + final imageFile = File(attachments.first.path); + final imageBytes = await imageFile.readAsBytes(); + final imageName = attachments.first.name; + + // Example image upload (replace with your actual image upload endpoint) + final uploadUrl = Uri.parse(DeliveryApi.uploadImage); // Adjust URL + final imageRequest = http.MultipartRequest('POST', uploadUrl) + ..files.add( + http.MultipartFile.fromBytes( + 'image', + imageBytes, + filename: imageName, + ), + ); + imageRequest.headers['Accept'] = 'application/json'; + + final imageResponse = await imageRequest.send(); + if (imageResponse.statusCode == 200) { + final imageJson = await http.Response.fromStream(imageResponse); + imageUrl = json.decode( + imageJson.body, + )['image_url']; // Assume response has 'image_url' + } else { + throw Exception('Image upload failed'); + } + } + + // Step 2: Create ticket with POST + final postUrl = Uri.parse(DeliveryApi.createSupportTicket); + final body = json.encode({ + 'userid': userid, + 'category': category, + 'priority': priority, + 'subject': subject, + 'issue': issue, + 'image': imageUrl, // null if no image + }); + + final response = await http.post( + postUrl, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: body, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to create ticket: ${response.statusCode}'); + } + + final Map jsonResponse = json.decode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + // Refresh tickets to show new one + await fetchTickets(); + return true; + } catch (e) { + errorMessage(e.toString()); + return false; + } finally { + isSubmitting(false); + } + } +} diff --git a/lib/xpress/services/maps/doormile_map.dart b/lib/xpress/services/maps/doormile_map.dart new file mode 100644 index 0000000..ef82625 --- /dev/null +++ b/lib/xpress/services/maps/doormile_map.dart @@ -0,0 +1,385 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import 'map_config.dart'; +import 'map_marker_icons.dart'; + +export 'package:maplibre_gl/maplibre_gl.dart' + show + LatLng, + LatLngBounds, + CameraPosition, + CameraUpdate, + MyLocationTrackingMode; + +/// Where a marker bitmap sits relative to its coordinate. +enum MarkerAnchor { center, bottom } + +@immutable +class DoormileMapMarker { + const DoormileMapMarker({ + required this.id, + required this.position, + required this.icon, + this.anchor = MarkerAnchor.bottom, + this.size = 1.0, + this.onTap, + }); + + final String id; + final LatLng position; + final MarkerIcon icon; + final MarkerAnchor anchor; + final double size; + final VoidCallback? onTap; + + /// Identity for diffing. Deliberately excludes [onTap] — a new closure on + /// every rebuild must not force the marker to be torn down and re-added. + @override + bool operator ==(Object other) => + other is DoormileMapMarker && + other.id == id && + other.position.latitude == position.latitude && + other.position.longitude == position.longitude && + other.icon == icon && + other.anchor == anchor && + other.size == size; + + @override + int get hashCode => Object.hash( + id, + position.latitude, + position.longitude, + icon, + anchor, + size, + ); +} + +@immutable +class DoormileMapPolyline { + const DoormileMapPolyline({ + required this.id, + required this.points, + required this.color, + this.width = 5.0, + }); + + final String id; + final List points; + final Color color; + final double width; + + @override + bool operator ==(Object other) => + other is DoormileMapPolyline && + other.id == id && + other.color == color && + other.width == width && + other.points.length == points.length && + (points.isEmpty || + (other.points.first.latitude == points.first.latitude && + other.points.first.longitude == points.first.longitude && + other.points.last.latitude == points.last.latitude && + other.points.last.longitude == points.last.longitude)); + + @override + int get hashCode => Object.hash(id, color, width, points.length); +} + +/// Thin, crash-tolerant facade over [MapLibreMapController]. +class DoormileMapController { + DoormileMapController._(this._raw, this._isAlive); + + final MapLibreMapController _raw; + final bool Function() _isAlive; + + /// Escape hatch for anything this facade does not wrap. + MapLibreMapController get raw => _raw; + + /// Frames [bounds] with uniform [padding] in logical pixels. + /// + /// Degenerate bounds (a single point, or a perfectly horizontal/vertical + /// pair) make the native fit routine fail, so they are nudged outward first. + Future fitBounds(LatLngBounds bounds, {double padding = 80}) async { + if (!_isAlive()) return; + const double minSpan = 0.0015; // ~165 m + + var south = bounds.southwest.latitude; + var north = bounds.northeast.latitude; + var west = bounds.southwest.longitude; + var east = bounds.northeast.longitude; + + if (north - south < minSpan) { + final mid = (north + south) / 2; + south = mid - minSpan / 2; + north = mid + minSpan / 2; + } + if (east - west < minSpan) { + final mid = (east + west) / 2; + west = mid - minSpan / 2; + east = mid + minSpan / 2; + } + + await _guard( + () => _raw.animateCamera( + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: LatLng(south, west), + northeast: LatLng(north, east), + ), + left: padding, + top: padding, + right: padding, + bottom: padding, + ), + ), + ); + } + + Future animateCamera(CameraUpdate update) => + _guard(() => _raw.animateCamera(update)); + + Future moveCamera(CameraUpdate update) => + _guard(() => _raw.moveCamera(update)); + + Future _guard(Future Function() action) async { + if (!_isAlive()) return; + try { + await action(); + } catch (e) { + debugPrint('[DOORMILE_MAP] Camera command skipped: $e'); + } + } +} + +/// The app's single map surface, backed by MapLibre + OpenStreetMap vector +/// tiles. No API key is involved anywhere in this widget. +/// +/// Markers and polylines are declarative: pass new lists and the widget diffs +/// them against what is on the map, updating only what actually changed. That +/// matters on the navigation screen, where the rider marker moves every few +/// metres and a clear-and-re-add would flicker. +class DoormileMap extends StatefulWidget { + const DoormileMap({ + super.key, + required this.initialCameraPosition, + this.markers = const [], + this.polylines = const [], + this.myLocationEnabled = false, + this.trackingMode = MyLocationTrackingMode.none, + this.compassEnabled = false, + this.zoomGesturesEnabled = true, + this.scrollGesturesEnabled = true, + this.rotateGesturesEnabled = true, + this.tiltGesturesEnabled = true, + this.onMapReady, + }); + + final CameraPosition initialCameraPosition; + final List markers; + final List polylines; + final bool myLocationEnabled; + final MyLocationTrackingMode trackingMode; + final bool compassEnabled; + final bool zoomGesturesEnabled; + final bool scrollGesturesEnabled; + final bool rotateGesturesEnabled; + final bool tiltGesturesEnabled; + final void Function(DoormileMapController controller)? onMapReady; + + @override + State createState() => _DoormileMapState(); +} + +class _DoormileMapState extends State { + MapLibreMapController? _controller; + DoormileMapController? _facade; + + bool _styleLoaded = false; + bool _disposed = false; + + final Set _registeredIcons = {}; + final Map _symbols = {}; + final Map _markerById = {}; + final Map _lines = {}; + final Map _polylineById = {}; + + bool _syncing = false; + bool _syncQueued = false; + + @override + void dispose() { + _disposed = true; + _controller?.onSymbolTapped.remove(_onSymbolTapped); + super.dispose(); + } + + bool _isAlive() => !_disposed && mounted && _styleLoaded; + + Future _onStyleLoaded() async { + final controller = _controller; + if (controller == null || _disposed) return; + + _styleLoaded = true; + // Delivery pins overlap constantly in dense areas; without this MapLibre + // silently hides the collided ones. + try { + await controller.setSymbolIconAllowOverlap(true); + await controller.setSymbolIconIgnorePlacement(true); + } catch (e) { + debugPrint('[DOORMILE_MAP] Overlap config skipped: $e'); + } + + await _sync(); + + final facade = _facade; + if (facade != null && !_disposed) widget.onMapReady?.call(facade); + } + + @override + void didUpdateWidget(covariant DoormileMap oldWidget) { + super.didUpdateWidget(oldWidget); + final markersChanged = !listEquals(oldWidget.markers, widget.markers); + final linesChanged = !listEquals(oldWidget.polylines, widget.polylines); + if (markersChanged || linesChanged) _sync(); + } + + /// Reconciles the widget's markers/polylines with what is on the map. + Future _sync() async { + final controller = _controller; + if (controller == null || !_styleLoaded || _disposed) return; + + // Native annotation calls are not re-entrant; coalesce overlapping syncs. + if (_syncing) { + _syncQueued = true; + return; + } + _syncing = true; + + try { + await _syncPolylines(controller); + await _syncMarkers(controller); + } catch (e) { + debugPrint('[DOORMILE_MAP] Annotation sync failed: $e'); + } finally { + _syncing = false; + if (_syncQueued && !_disposed) { + _syncQueued = false; + unawaited(_sync()); + } + } + } + + Future _syncPolylines(MapLibreMapController controller) async { + final desired = {for (final p in widget.polylines) p.id: p}; + + for (final id in _lines.keys.toList()) { + if (desired.containsKey(id)) continue; + final line = _lines.remove(id); + _polylineById.remove(id); + if (line != null) await controller.removeLine(line); + } + + for (final polyline in widget.polylines) { + if (polyline.points.length < 2) continue; + final existing = _lines[polyline.id]; + final options = LineOptions( + geometry: polyline.points, + lineColor: _hex(polyline.color), + lineWidth: polyline.width, + lineOpacity: 1.0, + lineJoin: 'round', + ); + + if (existing == null) { + _lines[polyline.id] = await controller.addLine(options); + } else if (_polylineById[polyline.id] != polyline) { + await controller.updateLine(existing, options); + } + _polylineById[polyline.id] = polyline; + } + } + + Future _syncMarkers(MapLibreMapController controller) async { + for (final marker in widget.markers) { + if (_registeredIcons.add(marker.icon.key)) { + try { + await controller.addImage(marker.icon.key, marker.icon.bytes); + } catch (e) { + _registeredIcons.remove(marker.icon.key); + debugPrint('[DOORMILE_MAP] Icon registration failed: $e'); + } + } + } + + final desired = {for (final m in widget.markers) m.id: m}; + + for (final id in _symbols.keys.toList()) { + if (desired.containsKey(id)) continue; + final symbol = _symbols.remove(id); + _markerById.remove(id); + if (symbol != null) await controller.removeSymbol(symbol); + } + + for (final marker in widget.markers) { + final options = SymbolOptions( + geometry: marker.position, + iconImage: marker.icon.key, + iconSize: marker.size, + iconAnchor: marker.anchor == MarkerAnchor.bottom ? 'bottom' : 'center', + ); + + final existing = _symbols[marker.id]; + if (existing == null) { + _symbols[marker.id] = await controller.addSymbol(options, { + 'doormileId': marker.id, + }); + } else if (_markerById[marker.id] != marker) { + await controller.updateSymbol(existing, options); + } + _markerById[marker.id] = marker; + } + } + + void _onSymbolTapped(Symbol symbol) { + final id = symbol.data?['doormileId']; + if (id is! String) return; + // Read the tap handler from the current widget, not the cached marker, so + // it always closes over fresh state. + for (final marker in widget.markers) { + if (marker.id == id) { + marker.onTap?.call(); + return; + } + } + } + + static String _hex(Color color) => + '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}'; + + @override + Widget build(BuildContext context) { + return MapLibreMap( + styleString: MapConfig.styleUrl, + initialCameraPosition: widget.initialCameraPosition, + myLocationEnabled: widget.myLocationEnabled, + myLocationTrackingMode: widget.trackingMode, + compassEnabled: widget.compassEnabled, + zoomGesturesEnabled: widget.zoomGesturesEnabled, + scrollGesturesEnabled: widget.scrollGesturesEnabled, + rotateGesturesEnabled: widget.rotateGesturesEnabled, + tiltGesturesEnabled: widget.tiltGesturesEnabled, + trackCameraPosition: true, + onMapCreated: (controller) { + _controller = controller; + _facade = DoormileMapController._(controller, _isAlive); + controller.onSymbolTapped.add(_onSymbolTapped); + }, + onStyleLoadedCallback: _onStyleLoaded, + ); + } +} diff --git a/lib/xpress/services/maps/map_config.dart b/lib/xpress/services/maps/map_config.dart new file mode 100644 index 0000000..3bffae1 --- /dev/null +++ b/lib/xpress/services/maps/map_config.dart @@ -0,0 +1,44 @@ +/// Map and routing endpoints. +/// +/// Everything here is open source and keyless: +/// +/// * **Tiles** — OpenFreeMap (), OpenStreetMap data +/// built with Planetiler and served as MapLibre vector tiles. No API key, no +/// registration, no request limits, MIT licensed. MapLibre adds the required +/// attribution automatically. +/// * **Routing** — OSRM (), the Open Source Routing +/// Machine. Also keyless. +/// +/// Both are overridable at build time so production can point at self-hosted +/// instances without a code change: +/// +/// flutter build apk \ +/// --dart-define=OSRM_BASE_URL=https://osrm.internal.doormile.in \ +/// --dart-define=MAP_STYLE_URL=https://tiles.internal.doormile.in/styles/liberty +class MapConfig { + const MapConfig._(); + + /// MapLibre style document. Passed straight to the map — no key to inject, + /// so unlike a commercial provider there is nothing to patch or proxy. + /// + /// OpenFreeMap styles: `liberty` (detailed), `bright`, `positron` (muted). + static const String styleUrl = String.fromEnvironment( + 'MAP_STYLE_URL', + defaultValue: 'https://tiles.openfreemap.org/styles/liberty', + ); + + /// Base URL of an OSRM instance, without a trailing slash. + /// + /// The default is the FOSSGIS community server. It is free and needs no key, + /// but it is donation-funded infrastructure with a fair-use policy — fine for + /// development, NOT appropriate for a production delivery fleet. Point this + /// at your own OSRM before shipping; see `deploy/osrm/README.md`. + static const String osrmBaseUrl = String.fromEnvironment( + 'OSRM_BASE_URL', + defaultValue: 'https://routing.openstreetmap.de/routed-car', + ); + + /// True when routing points somewhere other than the shared community server. + static bool get usingOwnRoutingServer => + !osrmBaseUrl.contains('routing.openstreetmap.de'); +} diff --git a/lib/xpress/services/maps/map_marker_icons.dart b/lib/xpress/services/maps/map_marker_icons.dart new file mode 100644 index 0000000..571d6bb --- /dev/null +++ b/lib/xpress/services/maps/map_marker_icons.dart @@ -0,0 +1,241 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +/// A marker bitmap plus a stable identity for it. +/// +/// MapLibre registers images on the style by name, so every distinct bitmap +/// needs a key that is equal whenever the pixels are equal. +@immutable +class MarkerIcon { + const MarkerIcon(this.key, this.bytes); + + final String key; + final Uint8List bytes; + + @override + bool operator ==(Object other) => other is MarkerIcon && other.key == key; + + @override + int get hashCode => key.hashCode; +} + +/// Marker bitmaps drawn at runtime with a canvas. +/// +/// MapLibre has no equivalent of Google's `BitmapDescriptor.defaultMarker*`, +/// so the pins the app used to get for free are drawn here instead. Everything +/// is generated in-process — no new image assets, no network fetch. +class MapMarkerIcons { + const MapMarkerIcons._(); + + static final Map _cache = {}; + + /// Classic teardrop pin, anchored at its tip. + static Future pin(Color color) => + _memo('pin_${color.toARGB32()}', () => _drawPin(color)); + + /// Filled circle with a white ring — used for the rider's own position. + static Future dot(Color color) => + _memo('dot_${color.toARGB32()}', () => _drawDot(color)); + + /// Numbered step marker for the multi-stop route screen. + static Future numberedCircle( + int number, { + required Color primaryColor, + bool isSkipped = false, + bool isNext = false, + }) => _memo( + 'step_${number}_${primaryColor.toARGB32()}_${isSkipped}_$isNext', + () => _drawNumberedCircle( + number, + primaryColor: primaryColor, + isSkipped: isSkipped, + isNext: isNext, + ), + ); + + static Future _memo( + String key, + Future Function() build, + ) async { + final cached = _cache[key]; + if (cached != null) return cached; + return _cache[key] = MarkerIcon(key, await build()); + } + + static Future _toBytes( + ui.PictureRecorder recorder, + int width, + int height, + ) async { + final image = await recorder.endRecording().toImage(width, height); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data!.buffer.asUint8List(); + } + + static Future _drawPin(Color color) async { + const double width = 72; + const double height = 96; + const double radius = 27; + const center = Offset(width / 2, radius + 4); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + final body = Paint() + ..color = color + ..isAntiAlias = true; + final outline = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..isAntiAlias = true; + + // Tail: a curve from the circle's lower flanks down to the anchor point. + final tail = Path() + ..moveTo(center.dx - radius + 4, center.dy + radius - 9) + ..quadraticBezierTo(center.dx - 6, height - 14, center.dx, height - 4) + ..quadraticBezierTo( + center.dx + 6, + height - 14, + center.dx + radius - 4, + center.dy + radius - 9, + ) + ..close(); + + canvas.drawPath(tail, body); + canvas.drawCircle(center, radius, body); + canvas.drawCircle(center, radius, outline); + canvas.drawCircle(center, 9, Paint()..color = Colors.white); + + return _toBytes(recorder, width.toInt(), height.toInt()); + } + + static Future _drawDot(Color color) async { + const double size = 48; + const center = Offset(size / 2, size / 2); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + canvas.drawCircle( + center, + 20, + Paint() + ..color = color.withValues(alpha: 0.25) + ..isAntiAlias = true, + ); + canvas.drawCircle( + center, + 12, + Paint() + ..color = Colors.white + ..isAntiAlias = true, + ); + canvas.drawCircle( + center, + 9, + Paint() + ..color = color + ..isAntiAlias = true, + ); + + return _toBytes(recorder, size.toInt(), size.toInt()); + } + + /// Preserves the exact look of the previous Google Maps step marker. + static Future _drawNumberedCircle( + int number, { + required Color primaryColor, + required bool isSkipped, + required bool isNext, + }) async { + const double size = 70; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + const center = Offset(size / 2, size / 2); + + final paint = Paint()..color = isSkipped ? Colors.orange : primaryColor; + canvas.drawCircle(center, 15, paint); + + final border = Paint() + ..color = isSkipped ? Colors.orange.shade900 : Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = isSkipped ? 4 : 3; + canvas.drawCircle(center, 15, border); + + final textPainter = TextPainter( + text: TextSpan( + text: number.toString(), + style: const TextStyle( + fontSize: 19, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset( + center.dx - textPainter.width / 2, + center.dy - textPainter.height / 2, + ), + ); + + if (isNext) { + final signPaint = Paint() + ..color = Colors.green + ..style = PaintingStyle.fill; + final signRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: const Offset(size / 2, 8), + width: 45, + height: 30, + ), + const Radius.circular(4), + ); + canvas.drawRRect(signRect, signPaint); + + final signBorder = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawRRect(signRect, signBorder); + + final nextTextPainter = TextPainter( + text: const TextSpan( + text: 'NEXT', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + nextTextPainter.layout(); + nextTextPainter.paint( + canvas, + Offset(size / 2 - nextTextPainter.width / 2, 4), + ); + + final arrowPath = Path() + ..moveTo(size / 2, 18) + ..lineTo(size / 2 - 4, 24) + ..lineTo(size / 2 + 4, 24) + ..close(); + canvas.drawPath( + arrowPath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill, + ); + } + + return _toBytes(recorder, size.toInt(), size.toInt()); + } +} diff --git a/lib/xpress/services/maps/routing_service.dart b/lib/xpress/services/maps/routing_service.dart new file mode 100644 index 0000000..9dee881 --- /dev/null +++ b/lib/xpress/services/maps/routing_service.dart @@ -0,0 +1,333 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import 'map_config.dart'; + +/// A road route returned by the routing backend. +class RouteResult { + const RouteResult({ + required this.points, + required this.distanceMeters, + required this.durationSeconds, + }); + + /// Decoded geometry of the route, ready to draw as a polyline. + final List points; + + /// Total road distance in metres. + final double distanceMeters; + + /// Total travel time in seconds. + final double durationSeconds; + + double get distanceKm => distanceMeters / 1000.0; + + bool get isEmpty => points.isEmpty && distanceMeters == 0; +} + +/// Road routing via OSRM — the Open Source Routing Machine. +/// +/// Replaces the Google Directions API (and `flutter_polyline_points`). OSRM is +/// keyless and self-hostable, so there is no credential to ship and no +/// per-request billing. +/// +/// Request shape: +/// GET {base}/route/v1/driving/{lon},{lat};{lon},{lat}?overview=full&geometries=polyline +/// +/// Note OSRM takes coordinates as **lon,lat** — the reverse of most APIs. +class RoutingService { + const RoutingService._(); + + static const Duration _timeout = Duration(seconds: 10); + static const Duration _cacheTtl = Duration(minutes: 30); + static const int _maxCacheEntries = 120; + + static final Map _cache = {}; + + /// Computes a driving route. [waypoints] are visited in order between + /// [origin] and [destination] — OSRM routes through all of them in a single + /// request, so a multi-stop route costs one call, not one per leg. + /// + /// Returns null when the service is unreachable or finds no route, so callers + /// can fall back to a straight line. + static Future getRoute({ + required LatLng origin, + required LatLng destination, + List waypoints = const [], + String profile = 'driving', + }) async { + if (!_isValid(origin) || !_isValid(destination)) { + debugPrint('[ROUTING] Invalid coordinates, skipping request.'); + return null; + } + + final cacheKey = _cacheKey(origin, destination, waypoints, profile); + final cached = _cache[cacheKey]; + if (cached != null && !cached.isExpired) return cached.result; + + // OSRM wants lon,lat pairs joined by ';' in the path itself. + final coordinates = [ + origin, + ...waypoints, + destination, + ].map((p) => '${p.longitude},${p.latitude}').join(';'); + + final uri = + Uri.parse( + '${MapConfig.osrmBaseUrl}/route/v1/$profile/$coordinates', + ).replace( + queryParameters: const { + 'overview': 'full', + 'geometries': 'polyline', + 'alternatives': 'false', + 'steps': 'false', + }, + ); + + try { + final response = await http.get(uri).timeout(_timeout); + + if (response.statusCode != 200) { + debugPrint('[ROUTING] HTTP ${response.statusCode}'); + return null; + } + + final result = _parse(json.decode(response.body)); + if (result == null || result.isEmpty) { + debugPrint('[ROUTING] No route in response.'); + return null; + } + + _store(cacheKey, result); + return result; + } catch (e) { + debugPrint('[ROUTING] Request failed: $e'); + return null; + } + } + + /// Convenience wrapper for callers that only need road distance. + static Future getRouteDistanceKm({ + required LatLng origin, + required LatLng destination, + }) async { + final route = await getRoute(origin: origin, destination: destination); + return route?.distanceKm; + } + + // --------------------------------------------------------------------- + // Response parsing + // --------------------------------------------------------------------- + + /// Parses an OSRM `route` response. Written tolerantly so a self-hosted + /// build, a proxy, or a drop-in alternative (Valhalla's OSRM-compatible + /// endpoint, GraphHopper's) does not require code changes: numeric fields are + /// accepted as plain numbers or as `{"value": n}` objects, and the geometry + /// is looked for under several key names. + static RouteResult? _parse(dynamic body) { + if (body is! Map) return null; + + final code = body['code']; + if (code is String && code != 'Ok') { + debugPrint('[ROUTING] Service returned code: $code'); + return null; + } + + final routes = body['routes']; + if (routes is! List || routes.isEmpty) return null; + + final route = routes.first; + if (route is! Map) return null; + + var distance = _num(route['distance']); + var duration = _num(route['duration']); + + // Fall back to summing legs when totals are absent. + if (distance == 0 || duration == 0) { + final legs = route['legs']; + if (legs is List) { + var legDistance = 0.0; + var legDuration = 0.0; + for (final leg in legs) { + if (leg is! Map) continue; + legDistance += _num(leg['distance']); + legDuration += _num(leg['duration']); + } + if (distance == 0) distance = legDistance; + if (duration == 0) duration = legDuration; + } + } + + final points = _extractGeometry(route); + if (points.isEmpty && distance == 0) return null; + + return RouteResult( + points: points, + distanceMeters: distance, + durationSeconds: duration, + ); + } + + static List _extractGeometry(Map route) { + for (final key in const ['geometry', 'overview_polyline', 'polyline']) { + final value = route[key]; + if (value is String && value.isNotEmpty) { + final decoded = decodePolyline(value); + if (decoded.isNotEmpty) return decoded; + } + // Encoded polyline nested as {"points": ""}. + if (value is Map && value['points'] is String) { + final decoded = decodePolyline(value['points'] as String); + if (decoded.isNotEmpty) return decoded; + } + // GeoJSON LineString, if the caller ever requests geometries=geojson. + if (value is Map && value['coordinates'] is List) { + final coords = value['coordinates'] as List; + final points = []; + for (final c in coords) { + if (c is List && c.length >= 2) { + points.add(LatLng(_num(c[1]), _num(c[0]))); + } + } + if (points.isNotEmpty) return points; + } + } + + // Last resort: stitch the per-step geometries together. + final points = []; + final legs = route['legs']; + if (legs is List) { + for (final leg in legs) { + if (leg is! Map) continue; + final steps = leg['steps']; + if (steps is! List) continue; + for (final step in steps) { + if (step is! Map) continue; + final geometry = step['geometry'] ?? step['polyline']; + final encoded = geometry is Map ? geometry['points'] : geometry; + if (encoded is String && encoded.isNotEmpty) { + points.addAll(decodePolyline(encoded)); + } + } + } + } + return points; + } + + static double _num(dynamic value) { + if (value == null) return 0.0; + if (value is num) return value.toDouble(); + if (value is Map) return _num(value['value']); + return double.tryParse(value.toString()) ?? 0.0; + } + + // --------------------------------------------------------------------- + // Polyline codec (Google's Encoded Polyline Algorithm) + // + // OSRM's default `geometries=polyline` uses precision 5. Its `polyline6` + // option uses precision 6 — pass `precision: 6` if you switch to it. + // --------------------------------------------------------------------- + + static List decodePolyline(String encoded, {int precision = 5}) { + final points = []; + final factor = _pow10(precision); + var index = 0; + var lat = 0; + var lng = 0; + + while (index < encoded.length) { + final dLat = _decodeValue(encoded, index); + if (dLat == null) break; + index = dLat.nextIndex; + lat += dLat.value; + + final dLng = _decodeValue(encoded, index); + if (dLng == null) break; + index = dLng.nextIndex; + lng += dLng.value; + + points.add(LatLng(lat / factor, lng / factor)); + } + return points; + } + + static _Delta? _decodeValue(String encoded, int index) { + var shift = 0; + var result = 0; + int byte; + do { + if (index >= encoded.length) return null; + byte = encoded.codeUnitAt(index++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20); + final value = (result & 1) != 0 ? ~(result >> 1) : (result >> 1); + return _Delta(value, index); + } + + static double _pow10(int exponent) { + var value = 1.0; + for (var i = 0; i < exponent; i++) { + value *= 10; + } + return value; + } + + // --------------------------------------------------------------------- + // Cache + // --------------------------------------------------------------------- + + static bool _isValid(LatLng p) => + p.latitude != 0 && + p.longitude != 0 && + p.latitude.abs() <= 90 && + p.longitude.abs() <= 180; + + static String _cacheKey( + LatLng origin, + LatLng destination, + List waypoints, + String profile, + ) { + // ~11 m resolution: identical pickup/drop pairs reuse one request. + String round(LatLng p) => + '${p.latitude.toStringAsFixed(4)},${p.longitude.toStringAsFixed(4)}'; + return [ + profile, + round(origin), + round(destination), + ...waypoints.map(round), + ].join('>'); + } + + static void _store(String key, RouteResult result) { + if (_cache.length >= _maxCacheEntries) { + final oldest = _cache.entries.reduce( + (a, b) => a.value.storedAt.isBefore(b.value.storedAt) ? a : b, + ); + _cache.remove(oldest.key); + } + _cache[key] = _CacheEntry(result, DateTime.now()); + } + + @visibleForTesting + static void clearCache() => _cache.clear(); +} + +class _Delta { + const _Delta(this.value, this.nextIndex); + final int value; + final int nextIndex; +} + +class _CacheEntry { + const _CacheEntry(this.result, this.storedAt); + final RouteResult result; + final DateTime storedAt; + + bool get isExpired => + DateTime.now().difference(storedAt) > RoutingService._cacheTtl; +} diff --git a/lib/xpress/utils/device.dart b/lib/xpress/utils/device.dart new file mode 100644 index 0000000..074efea --- /dev/null +++ b/lib/xpress/utils/device.dart @@ -0,0 +1,65 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DeviceUtils { + static const String _deviceIdKey = 'deviceId'; + static const String _fcmTokenKey = 'fcmToken'; + + static Future ensureDeviceId(SharedPreferences prefs) async { + final String? existing = prefs.getString(_deviceIdKey); + if (existing != null && existing.isNotEmpty) { + if (kDebugMode) print('[DEVICE] Using cached device ID: $existing'); + return existing; + } + try { + final deviceInfo = DeviceInfoPlugin(); + final android = await deviceInfo.androidInfo; + final String androidId = android.id; + if (androidId.isNotEmpty) { + await prefs.setString(_deviceIdKey, androidId); + return androidId; + } else { + throw Exception('Android ID is empty'); + } + } on PlatformException catch (e) { + throw Exception('Failed to get device ID: ${e.message}'); + } catch (e) { + throw Exception('Failed to get device ID: $e'); + } + } + + static Future ensureFcmToken(SharedPreferences prefs) async { + try { + final String? existing = prefs.getString(_fcmTokenKey); + if (existing != null && existing.isNotEmpty) { + return existing; + } + if (Firebase.apps.isEmpty) { + try { + await Firebase.initializeApp(); + } catch (_) { + return ''; + } + } + final FirebaseMessaging messaging = FirebaseMessaging.instance; + final NotificationSettings settings = await messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + if (settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional) { + final String? token = await messaging.getToken(); + if (token != null && token.isNotEmpty) { + await prefs.setString(_fcmTokenKey, token); + return token; + } + } + } catch (_) {} + return ''; + } +} diff --git a/lib/xpress/utils/kalman_filter.dart b/lib/xpress/utils/kalman_filter.dart new file mode 100644 index 0000000..f79737f --- /dev/null +++ b/lib/xpress/utils/kalman_filter.dart @@ -0,0 +1,252 @@ +import 'dart:math'; + +/// A simple 4D Kalman Filter implementation for GPS smoothing. +/// State vector x = [lat, lng, velocity_lat, velocity_lng] +class DoormileKalmanFilter { + late List x; // State estimate + late List> P; // Covariance matrix + late List> F; // State transition matrix + late List> H; // Measurement matrix + late List> R; // Measurement noise covariance + late List> Q; // Process noise covariance + + DoormileKalmanFilter({required double lat, required double lng}) { + // Initial state: [lat, lng, 0, 0] + x = [lat, lng, 0, 0]; + + // Initial covariance: High uncertainty for initial velocity + P = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1000.0, 0.0], + [0.0, 0.0, 0.0, 1000.0], + ]; + + // State transition matrix (assuming dt = 1 for simplicity, will update in predict) + F = [ + [1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + + // Measurement matrix: We only measure lat and lng + H = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + ]; + + // Measurement noise: GPS is typically accurate to ~5-10 meters. + // In degrees, this is roughly 0.0001 + R = [ + [0.00001, 0.0], + [0.0, 0.00001], + ]; + + // Process noise: How much we trust our prediction vs measurement + Q = [ + [0.00001, 0.0, 0.0, 0.0], + [0.0, 0.00001, 0.0, 0.0], + [0.0001, 0.0, 0.0001, 0.0], + [0.0, 0.0001, 0.0, 0.0001], + ]; + } + + /// Predict the next state + void predict(double dt) { + // Update F based on dt + F[0][2] = dt; + F[1][3] = dt; + + // x = F * x + final newX = List.filled(4, 0); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + newX[i] += F[i][j] * x[j]; + } + } + x = newX; + + // P = F * P * F^T + Q + final FP = _multiply4x4(F, P); + final F_T = _transpose4x4(F); + final FPF_T = _multiply4x4(FP, F_T); + + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + P[i][j] = FPF_T[i][j] + Q[i][j]; + } + } + } + + /// Update the state with a new measurement + void update(double measuredLat, double measuredLng) { + final z = [measuredLat, measuredLng]; + + // y = z - H * x (Innovation) + final y = [ + z[0] - + (H[0][0] * x[0] + H[0][1] * x[1] + H[0][2] * x[2] + H[0][3] * x[3]), + z[1] - + (H[1][0] * x[0] + H[1][1] * x[1] + H[1][2] * x[2] + H[1][3] * x[3]), + ]; + + // S = H * P * H^T + R (Innovation covariance) + // H is 2x4, P is 4x4, H^T is 4x2 + final HP = _multiply2x4_4x4(H, P); + final H_T = _transpose2x4(H); + final HPH_T = _multiply2x4_4x2(HP, H_T); + + final S = [ + [HPH_T[0][0] + R[0][0], HPH_T[0][1] + R[0][1]], + [HPH_T[1][0] + R[1][0], HPH_T[1][1] + R[1][1]], + ]; + + // K = P * H^T * S^-1 (Kalman gain) + final Sinv = _inverse2x2(S); + final PH_T = _multiply4x4_4x2(P, H_T); + final K = _multiply4x2_2x2(PH_T, Sinv); + + // x = x + K * y + for (int i = 0; i < 4; i++) { + x[i] += K[i][0] * y[0] + K[i][1] * y[1]; + } + + // P = (I - K * H) * P + final KH = _multiply4x2_2x4(K, H); + final I = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + + final I_KH = List.generate( + 4, + (i) => List.generate(4, (j) => I[i][j] - KH[i][j]), + ); + P = _multiply4x4(I_KH, P); + } + + // --- Helper Math Functions --- + + List> _multiply4x4(List> A, List> B) { + final C = List.generate(4, (_) => List.filled(4, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _transpose4x4(List> A) { + final C = List.generate(4, (_) => List.filled(4, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + C[i][j] = A[j][i]; + } + } + return C; + } + + List> _multiply2x4_4x4( + List> A, + List> B, + ) { + final C = List.generate(2, (_) => List.filled(4, 0)); + for (int i = 0; i < 2; i++) { + for (int j = 0; j < 4; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _transpose2x4(List> A) { + final C = List.generate(4, (_) => List.filled(2, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 2; j++) { + C[i][j] = A[j][i]; + } + } + return C; + } + + List> _multiply2x4_4x2( + List> A, + List> B, + ) { + final C = List.generate(2, (_) => List.filled(2, 0)); + for (int i = 0; i < 2; i++) { + for (int j = 0; j < 2; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _inverse2x2(List> A) { + final det = A[0][0] * A[1][1] - A[0][1] * A[1][0]; + if (det == 0) + return [ + [1, 0], + [0, 1], + ]; // Should not happen with noise + return [ + [A[1][1] / det, -A[0][1] / det], + [-A[1][0] / det, A[0][0] / det], + ]; + } + + List> _multiply4x4_4x2( + List> A, + List> B, + ) { + final C = List.generate(4, (_) => List.filled(2, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 2; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _multiply4x2_2x2( + List> A, + List> B, + ) { + final C = List.generate(4, (_) => List.filled(2, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 2; j++) { + for (int k = 0; k < 2; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _multiply4x2_2x4( + List> K, + List> H, + ) { + final C = List.generate(4, (_) => List.filled(4, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + for (int k = 0; k < 2; k++) { + C[i][j] += K[i][k] * H[k][j]; + } + } + } + return C; + } +} diff --git a/lib/xpress/utils/mqtt_service.dart b/lib/xpress/utils/mqtt_service.dart new file mode 100644 index 0000000..fdaa1e7 --- /dev/null +++ b/lib/xpress/utils/mqtt_service.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:mqtt_client/mqtt_client.dart'; +import 'package:mqtt_client/mqtt_server_client.dart'; +import 'package:miler/xpress/views/helpers/constants/mqtt_constants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DoormileMqttService { + static final DoormileMqttService _instance = DoormileMqttService._internal(); + + factory DoormileMqttService() { + return _instance; + } + + DoormileMqttService._internal(); + + MqttServerClient? _client; + bool _isConnected = false; + bool _isConnecting = false; // Prevents concurrent connection attempts + String? _currentRiderId; + + // Unique per isolate startup — prevents client ID collision between + // main isolate and background foreground-service isolate. + static final String _sessionSuffix = DateTime.now().millisecondsSinceEpoch + .toRadixString(36); + + bool get isConnected => _isConnected; + + Future connect() async { + if (_isConnected || _isConnecting) return; + _isConnecting = true; + + try { + final prefs = await SharedPreferences.getInstance(); + final riderIdLong = prefs.getInt('userId') ?? prefs.getInt('userid'); + if (riderIdLong == null || riderIdLong <= 0) { + debugPrint('[MQTT] Cannot connect: No rider ID found in preferences.'); + return; + } + _currentRiderId = riderIdLong.toString(); + + const host = MqttConstants.brokerHost; + final clientId = 'rider_${_currentRiderId}_$_sessionSuffix'; + + _client = MqttServerClient(host, clientId); + _client!.port = MqttConstants.brokerPort; + _client!.keepAlivePeriod = 30; + _client!.autoReconnect = true; + _client!.logging(on: false); + + final lwtTopic = MqttConstants.topicRiderStatus.replaceAll( + '{riderId}', + _currentRiderId!, + ); + _client!.onDisconnected = _onDisconnected; + _client!.onConnected = _onConnected; + _client!.onAutoReconnect = _onAutoReconnect; + _client!.onSubscribed = _onSubscribed; + + final connMessage = MqttConnectMessage() + .withClientIdentifier(clientId) + .authenticateAs(MqttConstants.username, MqttConstants.passwordString) + .withWillTopic(lwtTopic) + .withWillMessage(MqttConstants.statusOffline) + .withWillQos(MqttQos.atLeastOnce) + .withWillRetain() + .startClean(); + + _client!.connectionMessage = connMessage; + + debugPrint('[MQTT] Connecting to $host as $clientId...'); + await _client!.connect(); + } catch (e) { + debugPrint('[MQTT] Connection failed: $e'); + _cleanDisconnect(); + } finally { + _isConnecting = false; + } + } + + /// Cleanly disconnects and resets all state. Call on logout or duty end. + void disconnect() { + _publishStatus(MqttConstants.statusOffline); + _cleanDisconnect(); + debugPrint('[MQTT] Disconnected and state cleared.'); + } + + void _cleanDisconnect() { + _client?.disconnect(); + _client = null; + _isConnected = false; + _currentRiderId = null; + } + + void _onConnected() { + _isConnected = true; + debugPrint('[MQTT] Connected successfully.'); + _publishStatus(MqttConstants.statusOnline); + } + + void _onDisconnected() { + _isConnected = false; + debugPrint('[MQTT] Disconnected from broker.'); + } + + void _onAutoReconnect() { + debugPrint('[MQTT] Auto-reconnecting...'); + } + + void _onSubscribed(String topic) { + debugPrint('[MQTT] Subscribed to topic: $topic'); + } + + void _publishStatus(String status) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = MqttConstants.topicRiderStatus.replaceAll( + '{riderId}', + _currentRiderId!, + ); + final builder = MqttClientPayloadBuilder(); + builder.addString(status); + + _client!.publishMessage( + topic, + MqttQos.atLeastOnce, + builder.payload!, + retain: true, + ); + debugPrint('[MQTT] Published status: $status to $topic'); + } + + // --- PUBLIC API --- + + void updateStatus(String status) { + _publishStatus(status); + } + + void publishProfile(Map profileData) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = MqttConstants.topicRiderProfile.replaceAll( + '{riderId}', + _currentRiderId!, + ); + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(profileData)); + + _client!.publishMessage( + topic, + MqttQos.atLeastOnce, + builder.payload!, + retain: true, + ); + debugPrint('[MQTT] Published profile to $topic'); + } + + void publishLocation(Map locationData) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = MqttConstants.topicRiderLocation.replaceAll( + '{riderId}', + _currentRiderId!, + ); + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(locationData)); + + _client!.publishMessage(topic, MqttQos.atMostOnce, builder.payload!); + } + + void publishTelemetry(Map telemetryData) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = MqttConstants.topicRiderTelemetry.replaceAll( + '{riderId}', + _currentRiderId!, + ); + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(telemetryData)); + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); + debugPrint('[MQTT] Published telemetry data.'); + } + + void publishLog(String eventName, Map data) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = + '${MqttConstants.topicRiderLogs.replaceAll('{riderId}', _currentRiderId!)}/$eventName'; + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(data)); + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); + debugPrint('[MQTT] Published log: $eventName'); + } + + void publish(String subTopic, dynamic data) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = 'doormile/riders/$_currentRiderId/$subTopic'; + final builder = MqttClientPayloadBuilder(); + if (data is String) { + builder.addString(data); + } else { + builder.addString(jsonEncode(data)); + } + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); + debugPrint('[MQTT] Published to $topic'); + } +} diff --git a/lib/xpress/utils/no_internet.json b/lib/xpress/utils/no_internet.json new file mode 100644 index 0000000..fcb7dae --- /dev/null +++ b/lib/xpress/utils/no_internet.json @@ -0,0 +1 @@ +{"v":"5.5.8","fr":23.9499969482422,"ip":0,"op":120.000229360136,"w":800,"h":1100,"nm":"Comp 1","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":5,"ty":4,"nm":"Recurso-4 contornos","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-7,"ix":10},"p":{"a":0,"k":[324.896,329.642,0],"ix":2},"a":{"a":0,"k":[93.098,96.913,0],"ix":1},"s":{"a":0,"k":[324,324,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.27,"y":0},"t":0,"s":[{"i":[[-27.414,-10],[-8.411,-11.244],[-4.606,-13.604],[0.093,-13.712],[7.084,-12.207],[12.048,-7.832],[14.584,-1.43],[12.782,5.646],[10.395,9.93],[6.853,12.813],[-1.565,14.745],[-25.379,15.571]],"o":[[13.5,4.92],[8.411,11.244],[4.537,13.361],[-0.093,13.712],[-7.358,12.67],[-12.048,7.832],[-14.303,1.406],[-12.782,-5.646],[-10.707,-10.242],[-6.853,-12.813],[3.132,-29.601],[24.871,-15.254]],"v":[[40.531,-92.883],[73.077,-67.646],[92.281,-29.383],[99.538,11.692],[89.363,51.035],[59.716,82.178],[19.23,96.461],[-21.613,89.408],[-56.594,65.352],[-84.075,30.609],[-93.148,-10.887],[-41.348,-78.133]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.27,"y":0},"t":36,"s":[{"i":[[-16.89,-3.382],[-13.011,-10.914],[-3.996,-16.527],[2.535,-16.18],[10.004,-13.445],[16.019,-5.395],[16.148,3.809],[11.309,11.844],[5.652,13.934],[-1.387,15.407],[-9.441,13.183],[-15.953,6.125]],"o":[[16.656,3.329],[13.012,10.91],[3.848,15.918],[-2.586,16.566],[-10.09,13.57],[-15.75,5.3],[-15.945,-3.75],[-10.379,-10.875],[-5.82,-14.324],[1.57,-16.14],[9.848,-13.973],[16.078,-6.183]],"v":[[12.406,-93.938],[58.156,-73.566],[86.25,-32.02],[79.77,15.84],[71.141,64.535],[26.723,87.18],[-21.457,95.746],[-60.043,66.695],[-84.492,29.707],[-93.238,-15.055],[-76.43,-59.82],[-38.375,-92.879]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.27,"y":0},"t":84,"s":[{"i":[[-17.047,-0.609],[-7.139,-4.535],[-7.222,-4.422],[-8.004,-15.773],[0.508,-17.871],[12.336,-17.039],[20.57,-4.313],[19.16,4.742],[10.304,17.699],[1.371,20.184],[-7.746,16.86],[-16.418,8.625]],"o":[[8.473,0.315],[7.139,4.535],[15.098,9.223],[8.078,15.942],[-0.593,21],[-12.34,17.043],[-19.312,4.035],[-19.906,-4.918],[-10.16,-17.489],[-1.211,-18.5],[7.75,-16.859],[15.082,-7.914]],"v":[[1.09,-96.977],[24.153,-88.62],[45.34,-74.102],[87.738,-44.004],[87.23,8.531],[84.492,70.293],[26.742,92.043],[-31.211,94.465],[-83.652,64.766],[-83.027,5.312],[-91.266,-48.551],[-45.062,-77.426]],"c":true}]},{"t":119.999985219045,"s":[{"i":[[-27.414,-10],[-8.411,-11.244],[-4.606,-13.604],[0.093,-13.712],[7.084,-12.207],[12.048,-7.832],[14.584,-1.43],[12.782,5.646],[10.395,9.93],[6.853,12.813],[-1.565,14.745],[-25.379,15.571]],"o":[[13.5,4.92],[8.411,11.244],[4.537,13.361],[-0.093,13.712],[-7.358,12.67],[-12.048,7.832],[-14.303,1.406],[-12.782,-5.646],[-10.707,-10.242],[-6.853,-12.813],[3.132,-29.601],[24.871,-15.254]],"v":[[40.531,-92.883],[73.077,-67.646],[92.281,-29.383],[99.538,11.692],[89.363,51.035],[59.716,82.178],[19.23,96.461],[-21.613,89.408],[-56.594,65.352],[-84.075,30.609],[-93.148,-10.887],[-41.348,-78.133]],"c":true}]}],"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.05],"y":[1]},"o":{"x":[0.15],"y":[0]},"t":0,"s":[0.901960790157,0.917647063732,0.92549020052,1]},{"t":119.999985219045,"s":[0.901960790157,0.917647063732,0.92549020052,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[93.5,97],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3404.99956612796,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Capa de formas 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,550,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"personaje 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,550,0],"ix":2},"a":{"a":0,"k":[400,550,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.9,1.268],[-2.281,7.988],[-7.905,-0.305],[-0.87,-5.147],[3.744,-4.083],[1.712,-0.428]],"o":[[0,0],[2.28,-7.988],[5.123,0.197],[0.923,5.462],[-5.189,5.658],[-2.432,0.609]],"v":[[-11.649,15.253],[-13.549,-2.625],[6.973,-18.6],[14.907,-8.949],[9.949,6.101],[-2.908,18.296]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[514.832,480.61],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 5","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.926,0.896],[0,0],[-1.556,0],[0,0],[0,0],[-1.385,-1.04],[0,0],[0.835,-0.865],[0,0],[2.171,0],[0,0],[-0.237,0.709],[0,0],[0,0]],"o":[[0,0],[1.119,-1.081],[0,0],[0,0],[1.732,0],[0,0],[0.962,0.722],[0,0],[-1.508,1.563],[0,0],[-0.748,0],[0,0],[0,0],[0.407,-1.222]],"v":[[-68.842,27.355],[-5.488,-33.88],[-1.318,-35.565],[-1.084,-35.565],[64.276,-35.565],[69.079,-33.964],[71.215,-32.359],[71.454,-29.372],[11.157,33.121],[5.4,35.565],[-71.012,35.565],[-72.052,34.123],[-71.899,33.664],[-70.872,30.579]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.803921987496,0.83529399797,0.854902020623,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[476.143,468.285],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 6","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[15.324,5.024],[0,0]],"o":[[0,0],[-11.085,6.021],[0,0],[0,0]],"v":[[13.769,-14.683],[19.781,6.978],[-14.318,9.659],[-19.781,-14.605]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[412.75,380.856],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 7","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[44.299,-5.466],[1.222,-14.421],[-6.337,-0.231],[0,0],[0,0],[-10.12,-4.136],[-4.647,3.813],[-8.467,-9.575],[-1.553,-10.834],[15.77,-16.455]],"o":[[-27.277,3.365],[0,0],[2.61,1.934],[0,0],[-0.154,-10.99],[10.121,4.135],[4.647,-3.814],[3.963,4.481],[15.336,-23.7],[-27.513,6.367]],"v":[[-23.511,-43.171],[-63.5,-6.424],[-59.845,33.105],[-49.304,51.552],[-45.65,52.598],[-30.009,25.297],[-20.881,-2.492],[41.489,3.557],[40.907,37.414],[50.412,-36.143]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.588234994926,0.36470600203,0.317646998985,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[413.459,268.428],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 8","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.254,-14.17],[10.39,-4.544],[12.978,6.984],[4.811,19.228],[5.553,10.624],[-12.412,-1.526],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[-0.254,14.17],[-12.705,1.914],[-16.464,-8.684],[-7.053,0.581],[-4.208,-14.519],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.019,7.907]],"v":[[51.972,35.106],[20.485,66.448],[-12.401,59.804],[-40.844,25.156],[-57.024,12.612],[-47.31,-4.785],[-37.5,13.453],[-42.19,-22.705],[-8.194,-68.362],[61.232,-48.491],[52.347,-6.328]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[401.655,306.527],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 9","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.26,62.228],[0,0],[0,0],[-11.782,0.38],[0,0],[6.081,-66.186],[0,0],[4.94,12.552],[-0.075,-39.851],[0.351,-0.074],[30.912,10.646],[0.76,0.945],[0.225,0.908]],"o":[[2.406,-7.916],[0,0],[0,0],[11.781,-0.38],[50.926,10.271],[0,0],[0,-20.921],[-20.565,63.912],[0.001,0.358],[-30.156,6.392],[-0.882,-0.303],[-0.76,-0.945],[-6.117,-24.716]],"v":[[-26.502,-4.753],[-13.96,-41.268],[16.444,-43.171],[32.786,-43.171],[50.648,-45.833],[101.194,39.753],[77.631,39.753],[72.691,7.42],[59.388,126.234],[58.784,126.987],[-26.122,126.479],[-28.782,124.958],[-29.883,122.294]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[381.846,433.668],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 10","np":2,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-3.729,3.076],[1.371,8.788],[2.632,-1.318],[1.919,1.922],[0.548,-2.746],[3.564,-0.275],[1.645,-2.746],[-6.169,1.686],[-1.736,3.661]],"o":[[3.015,-7.964],[-0.731,-0.549],[-3.289,1.648],[-1.919,-1.922],[-0.548,2.746],[-3.564,0.274],[-8.443,14.094],[4.825,-1.318],[2.193,-0.366]],"v":[[28.21,4.486],[29.032,-19.68],[22.726,-18.581],[15.05,-22.151],[0.794,-22.151],[-3.867,-7.322],[-22.783,10.802],[0.794,22.335],[16.969,10.802]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[359.663,835.492],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 11","np":2,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[31.757,9.095],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[-32.348,7.023],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[34.584,-123.433],[-47.506,-125.716],[-37.245,122.292],[-14.442,122.292],[-6.081,-67.138],[24.323,125.716],[47.506,125.716]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.1450980392156863,0.15294117647058825,0.27058823529411763,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[402.85,681.678],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 12","np":2,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[3.729,3.076],[-1.371,8.787],[-2.632,-1.318],[-1.919,1.922],[-0.548,-2.746],[-3.564,-0.275],[-1.645,-2.746],[6.169,1.685],[1.736,3.661]],"o":[[-3.015,-7.964],[0.731,-0.55],[3.29,1.648],[1.919,-1.922],[0.548,2.746],[3.564,0.274],[8.442,14.094],[-4.825,-1.319],[-2.194,-0.366]],"v":[[-28.21,4.487],[-29.032,-19.678],[-22.727,-18.581],[-15.05,-22.15],[-0.794,-22.15],[3.866,-7.321],[22.783,10.802],[-0.794,22.336],[-16.969,10.802]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[457.439,838.535],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 13","np":2,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[8.779,12.521],[8.603,-15.467],[-8.779,-15.816],[-8.002,15.817]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[375.867,809.741],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 14","np":2,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-6.973,14.451],[-10.079,-13.365],[7.144,-15.753],[10.08,15.753]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[439.06,809.556],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 15","np":2,"cix":2,"bm":0,"ix":11,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"wifi_icon","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":43,"s":[45]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":81,"s":[-31]},{"t":119.000190751185,"s":[0]}],"ix":10},"p":{"a":0,"k":[198.575,-239.681,0],"ix":2},"a":{"a":0,"k":[198.575,-239.681,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.417,0],[-0.417,0],[-0.834,0.827],[0,1.24],[0.834,0.827],[1.668,-1.654],[0.105,-0.207],[0.209,-0.206],[0,-0.414],[-0.834,-0.828]],"o":[[0.417,0.413],[1.251,0],[0.834,-0.828],[0,-0.827],[-1.668,-1.654],[-0.208,0.207],[-0.104,0.207],[-0.417,0.414],[0,1.24],[0.417,0.413]],"v":[[7.693,37.023],[9.362,37.435],[12.281,36.195],[13.532,33.301],[12.281,30.406],[6.442,30.406],[6.025,31.026],[5.609,31.646],[5.191,33.301],[6.442,36.195]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.251,0],[0.834,0.413],[7.09,-4.963],[1.251,1.654],[-1.668,1.241],[-10.009,-7.03],[1.668,-2.068]],"o":[[-0.834,0],[-7.09,-4.963],[-2.085,1.24],[-1.251,-2.068],[10.009,-7.03],[2.085,1.241],[-0.417,1.24]],"v":[[23.958,21.308],[21.457,20.481],[-2.733,20.481],[-8.571,19.654],[-7.736,13.864],[26.043,13.864],[26.878,19.654]],"c":true},"ix":2},"nm":"Trazado 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[18.23,-14.865],[-1.669,-1.654],[-1.251,0],[-0.417,1.241],[-15.43,-12.82],[-1.315,0.421]],"o":[[-18.658,-14.017],[-1.668,1.241],[0.834,1.24],[0.834,0],[15.431,-12.82],[1.069,1.061],[0,0]],"v":[[39.848,-2.297],[-22.75,-1.024],[-23.167,4.766],[-19.831,6.419],[-17.329,5.179],[36.052,5.179],[39.848,6.029]],"c":true},"ix":2},"nm":"Trazado 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,0],[21.704,-18.832],[-1.668,-1.654],[-1.252,0],[-0.834,0.413],[-22.824,-12.68]],"o":[[-25.104,-11.795],[-1.668,1.655],[0.834,0.827],[1.251,0],[20.027,-17.732],[0,0]],"v":[[39.848,-25.641],[-37.764,-15.086],[-38.181,-9.295],[-34.844,-8.055],[-31.924,-8.881],[39.848,-16.459]],"c":true},"ix":2},"nm":"Trazado 4","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Combinar trazados 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.207843002619,0.341175991881,0.411765005074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[185.041,-243.891],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.083,0],[0,0],[0,-5.083],[0,0],[5.083,0],[0,5.083],[0,0]],"o":[[0,0],[5.083,0],[0,0],[0,5.083],[-5.083,0],[0,0],[0,-5.083]],"v":[[-0.001,-9.313],[-0.001,-9.313],[9.204,-0.109],[9.204,0.108],[-0.001,9.313],[-9.204,0.108],[-9.204,-0.109]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.207843002619,0.341175991881,0.411765005074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[242.147,-204.502],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.083,0],[0,0],[0,-5.083],[0,0],[5.083,0],[0,5.083],[0,0]],"o":[[0,0],[5.083,0],[0,0],[0,5.083],[-5.083,0],[0,0],[0,-5.083]],"v":[[-0.001,-26.902],[-0.001,-26.902],[9.204,-17.699],[9.204,17.699],[-0.001,26.902],[-9.204,17.699],[-9.204,-17.699]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.207843002619,0.341175991881,0.411765005074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[242.147,-257.271],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":17.9999977064033,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"burbuja","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":43,"s":[-45]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":81,"s":[31]},{"t":119.000227448802,"s":[0]}],"ix":10},"p":{"a":0,"k":[534.277,421.219,0],"ix":2},"a":{"a":0,"k":[101.277,-141.781,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-15.958,0],[0,52.655],[52.609,0],[0,-52.655],[-6.732,-13.072],[2.195,-5.158],[0,0],[-3.508,1.495],[0,0],[-5.004,-2.629]],"o":[[52.609,0],[0,-52.655],[-52.609,0],[0,15.719],[2.566,4.983],[0,0],[-1.494,3.511],[0,0],[5.2,-2.216],[13.213,6.942]],"v":[[1.724,93.868],[96.981,-1.472],[1.724,-96.813],[-93.534,-1.472],[-83.001,42.15],[-82.039,58.154],[-95.487,89.759],[-89.932,95.318],[-58.606,81.964],[-42.489,82.999]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,0.663,0.678,0.729,0.5,0.739,0.767,0.804,1,0.816,0.855,0.878],"ix":9}},"s":{"a":0,"k":[64,-66],"ix":5},"e":{"a":0,"k":[-62,38],"ix":6},"t":2,"h":{"a":0,"k":0.7,"ix":7},"a":{"a":0,"k":234,"ix":8},"nm":"Relleno de degradado 1","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[196.706,-238.209],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 4","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"brazo","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":20,"s":[-11]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":48,"s":[0]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":92,"s":[-11]},{"t":119.999985219045,"s":[0]}],"ix":10},"p":{"a":0,"k":[374.034,417.646,0],"ix":2},"a":{"a":0,"k":[374.909,419.896,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.921,13.447],[25.176,53.268],[-3.379,3.533],[-8.475,6.474],[5.321,-12.784],[4.236,-3.527],[-24.878,-4.934],[7.695,-18.024]],"o":[[-34.472,-21.175],[-2.089,-4.421],[9.19,-9.611],[12.542,-3.466],[-3.643,2.928],[13.303,42.17],[0,0],[-7.695,18.024]],"v":[[-26.502,-4.753],[-105.186,-96.689],[-103.079,-109.921],[-77.048,-133.658],[-57.286,-113.69],[-69.067,-104.031],[-13.96,-41.269],[8.224,-8.442]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[381.846,433.668],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 10","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[14.062,-13.694],[24.703,-26.246],[0,0],[-24.159,11.492],[-5.186,-1.617],[0,0]],"o":[[0,0],[-4.542,-1.803],[-26.177,6.526],[0,0],[26.324,-22.481],[0,0],[5.186,1.617],[0,0]],"v":[[50.197,-32.902],[45.205,-36.596],[24.893,-10.08],[-57.197,41.271],[-32.494,46.596],[32.114,3.233],[39.227,2.983],[56.147,-26.711]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[340.712,281.899],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 16","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"direcciones","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[216.489,620.463,0],"ix":2},"a":{"a":0,"k":[216.489,616.463,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-72.652,41.979],[-85.257,-11.821],[57.865,-41.979],[85.257,-18.157],[72.955,7.807]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.7607843137254902,0.19607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[229.262,629.861],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 17","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[79.268,33.382],[84.972,-21.584],[-60.81,-33.382],[-84.972,-6.284],[-69.489,17.915]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9372549019607843,0.1803921568627451,0.3568627450980392,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[203.43,528.396],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 18","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-71.052,43.768],[-85.137,-9.664],[57.097,-43.768],[85.137,-20.713],[73.557,5.582]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9725490196078431,0.6941176470588235,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[209.912,440.108],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 19","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.005,-236.359],[-32.21,-234.631],[15.006,236.359],[32.21,234.631]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.337254991718,0.254901990704,0.172548989689,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[220.064,616.463],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 21","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"piso","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,1008,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[790,181.7,100],"ix":6}},"ao":0,"shapes":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-35,0]],"o":[[0,0],[0,0],[0,0],[0,0],[35,0]],"v":[[50,-50],[50,50],[-50,50],[-50,-50],[0.25,-98.913]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,0.769,0.769,0.769,0.5,0.776,0.802,0.818,1,0.784,0.835,0.867,0.717,0,0.859,0.4,1,0.8],"ix":9}},"s":{"a":0,"k":[0,0],"ix":5},"e":{"a":0,"k":[100,0],"ix":6},"t":2,"h":{"a":0,"k":0,"ix":7},"a":{"a":0,"k":0,"ix":8},"nm":"Relleno de degradado 1","mn":"ADBE Vector Graphic - G-Fill","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"blobs","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,550,0],"ix":2},"a":{"a":0,"k":[400,550,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":1100,"ip":0,"op":120.000229360136,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/lib/xpress/views/Dashboard/Cart/cartpage.dart b/lib/xpress/views/Dashboard/Cart/cartpage.dart new file mode 100644 index 0000000..7fb8009 --- /dev/null +++ b/lib/xpress/views/Dashboard/Cart/cartpage.dart @@ -0,0 +1,1199 @@ +part of '../deliveries/deliveries.dart'; + +// ------------------------------------------------------------------------- +// CART PAGE (Active Deliveries) +// ------------------------------------------------------------------------- +class Cartpage extends StatefulWidget { + const Cartpage({super.key}); + + @override + State createState() => _CartpageState(); +} + +class _CartpageState extends State + with AutomaticKeepAliveClientMixin { + final DeliveryProvider _provider = DeliveryProvider(); + final CreateDeliveryLogProvider _deliveryLogProvider = + CreateDeliveryLogProvider(); + + List> _activeDeliveries = >[]; + StreamSubscription? _pollerSubscription; + bool _fetching = false; + final Map _deliveryTimers = {}; + final Map> _deliveryBasePayload = + >{}; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _fetchActive(); + _startPolling(); + } + + @override + void dispose() { + _pollerSubscription?.cancel(); + _stopAllTimers(); + super.dispose(); + } + + void _startPolling() { + _pollerSubscription?.cancel(); + _pollerSubscription = Stream.periodic(const Duration(seconds: 10), (_) {}) + .asyncMap((_) async { + if (!_fetching && mounted) { + await _fetchActive(); + } + }) + .listen( + (_) {}, + onError: (error) { + debugPrint('[CART][STREAM ERROR] $error'); + }, + cancelOnError: false, + ); + } + + Future _fetchActive() async { + if (_fetching) return; + + _fetching = true; + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + + if (userId == 0) { + debugPrint('[CART] No user ID found'); + _fetching = false; + return; + } + + // Get current date in YYYY-MM-DD format + final now = DateTime.now(); + final today = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + + // One Doormile route now — see [DeliveryApi]. + final baseUrl = DeliveryApi.currentDeliveries; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + debugPrint('[CART] Fetching from: $uri'); + + // Fetch deliveries from API directly using http + // Hardcoded endpoint for cart page: v2/deliveries/getdeliveries + final httpClient = http.Client(); + List items = []; + try { + final response = await httpClient.get(uri); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + items = data is List + ? data + : (data is Map && data['items'] is List + ? data['items'] as List + : []); + } else { + debugPrint('[CART] API error: ${response.statusCode}'); + } + } catch (e) { + debugPrint('[CART] Error fetching from API: $e'); + } finally { + httpClient.close(); + } + + debugPrint('[CART] Raw API items count: ${items.length}'); + + // Debug: Print all order statuses to see what we're getting + for (final item in items) { + if (item is Map) { + final orderId = (item['orderid'] ?? '').toString(); + final status = (item['orderstatus'] ?? '').toString(); + debugPrint( + '[CART] Order $orderId has status: "$status" (raw: ${item['orderstatus']})', + ); + } + } + + // Filter for ACTIVE status only + final activeOrders = items.whereType>().where(( + order, + ) { + final status = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isActive = status == 'active'; + if (isActive) { + debugPrint('[CART] ✅ Found active order: ${order['orderid']}'); + } else { + debugPrint( + '[CART] ❌ Order ${order['orderid']} has status: "$status" (not active)', + ); + } + return isActive; + }).toList(); + + debugPrint( + '[CART] Found ${activeOrders.length} active deliveries out of ${items.length} total', + ); + + // Sort by order ID or step number if available + activeOrders.sort((a, b) { + final stepA = (a['step'] ?? a['Step'] ?? 0).toString(); + final stepB = (b['step'] ?? b['Step'] ?? 0).toString(); + final stepAInt = int.tryParse(stepA) ?? 0; + final stepBInt = int.tryParse(stepB) ?? 0; + if (stepAInt != stepBInt) return stepAInt.compareTo(stepBInt); + + final orderIdA = (a['orderid'] ?? '').toString(); + final orderIdB = (b['orderid'] ?? '').toString(); + return orderIdA.compareTo(orderIdB); + }); + + if (mounted) { + setState(() { + _activeDeliveries = activeOrders; + }); + } + + // Get all active order IDs + final activeOrderIds = activeOrders + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ CRITICAL: Start/restart timers for ALL active deliveries + // This ensures every active delivery posts logs every 30 seconds + for (final order in activeOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) { + debugPrint('[CART] ⚠️ Skipping order with empty orderId'); + continue; + } + + // Always restart timer to ensure it's running (handles edge cases) + if (_deliveryTimers.containsKey(orderId)) { + debugPrint( + '[CART] 🔄 Restarting timer for active delivery: $orderId', + ); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + // Also clear base payload to force reload + _deliveryBasePayload.remove(orderId); + } + + debugPrint('[CART] ▶️ Starting timer for active delivery: $orderId'); + await _startDeliveryPosting(order); + debugPrint('[CART] ✅ Timer started successfully for: $orderId'); + } + + // ✅ Stop timers for deliveries that are no longer active + final timersToStop = _deliveryTimers.keys + .where((id) => !activeOrderIds.contains(id)) + .toList(); + + for (final id in timersToStop) { + debugPrint('[CART] Stopping timer for orderId: $id (no longer active)'); + _stopDeliveryPosting(id); + } + + debugPrint('[CART] Active timers: ${_deliveryTimers.keys.toList()}'); + } catch (e) { + debugPrint('[CART] Error fetching active deliveries: $e'); + } finally { + _fetching = false; + } + } + + Future _startDeliveryPosting(Map order) async { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) { + debugPrint('[CART][DELIVERYLOG] ⚠️ Cannot start timer: empty orderId'); + return; + } + + // Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above) + if (_deliveryTimers.containsKey(orderId)) { + debugPrint( + '[CART][DELIVERYLOG] ⚠️ Timer already exists for $orderId, canceling old one', + ); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + } + + debugPrint( + '[CART][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId', + ); + + // Get starttime from SharedPreferences (saved when order became active via updateActiveStatus) + // If not found, use activetime from order data, or current time as fallback + String startTime = ''; + try { + final prefs = await SharedPreferences.getInstance(); + final deliveryId = (order['deliveryid'] ?? 0).toString(); + + // Method 1: Get from SharedPreferences (saved when order became active) + startTime = prefs.getString('delivery_starttime_$deliveryId') ?? ''; + if (startTime.isNotEmpty) { + debugPrint( + '[CART][DELIVERYLOG] ✅ Loaded starttime from SharedPreferences: $startTime', + ); + } + + // Method 2: Fallback - try to get from order data (starttime field) + if (startTime.isEmpty) { + startTime = (order['starttime'] ?? order['startTime'] ?? '').toString(); + if (startTime.isNotEmpty) { + debugPrint( + '[CART][DELIVERYLOG] ✅ Loaded starttime from order data: $startTime', + ); + } + } + + // Method 3: Fallback - try activetime from order data + if (startTime.isEmpty) { + final activetime = (order['activetime'] ?? order['activTime'] ?? '') + .toString(); + if (activetime.isNotEmpty) { + startTime = activetime; + debugPrint( + '[CART][DELIVERYLOG] ✅ Loaded starttime from activetime: $startTime', + ); + } + } + + // Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called) + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint( + '[CART][DELIVERYLOG] ⚠️ Using current time as starttime fallback: $startTime', + ); + } + } catch (e) { + debugPrint('[CART][DELIVERYLOG] ❌ Error getting starttime: $e'); + // Set a fallback starttime even on error + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + } + + // CRITICAL: Ensure starttime is never empty + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint( + '[CART][DELIVERYLOG] ⚠️ Final fallback: starttime was empty, using: $startTime', + ); + } + + debugPrint( + '[CART][DELIVERYLOG] 📝 Final starttime for orderId $orderId: $startTime', + ); + + // Create base payload with starttime + final base = { + 'logid': 0, + 'tenantid': order['tenantid'] ?? 0, + 'partnerid': order['partnerid'] ?? 0, + 'locationid': order['locationid'] ?? 0, + 'orderheaderid': order['orderheaderid'] ?? 0, + 'deliveryid': order['deliveryid'] ?? 0, + 'userid': order['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'active', + 'starttime': startTime, // Include starttime in base payload + }; + + _deliveryBasePayload[orderId] = base; + + // Save to SharedPreferences for persistence + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'deliverylog_${orderId}_tenantid', + (base['tenantid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_partnerid', + (base['partnerid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_locationid', + (base['locationid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_orderheaderid', + (base['orderheaderid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_deliveryid', + (base['deliveryid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_userid', + (base['userid'] ?? 0).toString(), + ); + await prefs.setString('deliverylog_${orderId}_orderid', orderId); + await prefs.setString('deliverylog_${orderId}_orderstatus', 'active'); + await prefs.setString( + 'deliverylog_${orderId}_starttime', + startTime, + ); // Save starttime + } catch (e) { + debugPrint('[CART][DELIVERYLOG] Error saving payload: $e'); + } + + // Post once immediately (don't await - let it run in background) + _postDeliveryLog(orderId, order); + debugPrint( + '[CART][DELIVERYLOG] 📤 Posted initial log for orderId: $orderId', + ); + + // Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds + final timer = Timer.periodic(const Duration(seconds: 30), (t) { + debugPrint( + '[CART][DELIVERYLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)', + ); + _postDeliveryLog(orderId, order); + }); + + _deliveryTimers[orderId] = timer; + debugPrint( + '[CART][DELIVERYLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)', + ); + } + + void _postDeliveryLog(String orderId, Map order) { + if (!mounted) { + debugPrint('[CART][DELIVERYLOG][POST] Widget disposed, skipping'); + return; + } + + debugPrint( + '[CART][DELIVERYLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}', + ); + + // Use Future.microtask to ensure the async operation runs independently + Future.microtask(() => _performPost(orderId)); + } + + Future _performPost(String orderId) async { + try { + debugPrint( + '[CART][DELIVERYLOG][POST] 🔄 Starting _performPost for orderId: $orderId', + ); + + Map? base = _deliveryBasePayload[orderId]; + + if (base == null) { + debugPrint( + '[CART][DELIVERYLOG][POST] Base is null, loading from SharedPreferences', + ); + try { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) { + debugPrint( + '[CART][DELIVERYLOG][POST] Widget unmounted after prefs load', + ); + return; + } + + base = { + 'logid': 0, + 'tenantid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_tenantid') ?? '0', + ) ?? + 0, + 'partnerid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_partnerid') ?? '0', + ) ?? + 0, + 'locationid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_locationid') ?? '0', + ) ?? + 0, + 'orderheaderid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_orderheaderid') ?? + '0', + ) ?? + 0, + 'deliveryid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0', + ) ?? + 0, + 'userid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_userid') ?? '0', + ) ?? + 0, + 'orderid': + prefs.getString('deliverylog_${orderId}_orderid') ?? orderId, + 'orderstatus': + prefs.getString('deliverylog_${orderId}_orderstatus') ?? + 'active', + 'starttime': + prefs.getString('deliverylog_${orderId}_starttime') ?? + '', // Load starttime + }; + debugPrint('[CART][DELIVERYLOG][POST] Base loaded from prefs: $base'); + } catch (e) { + debugPrint('[CART][DELIVERYLOG][POST] Error loading base: $e'); + return; + } + } + + // At this point, base is guaranteed to be non-null (either from cache or created above) + final basePayload = base; // Flow analysis ensures base is non-null here + + debugPrint('[CART][DELIVERYLOG][POST] Getting coordinates...'); + + // CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates + final coords = await _getValidCoordinates().timeout( + const Duration(seconds: 10), // Increased timeout to allow retries + onTimeout: () { + debugPrint( + '[CART][DELIVERYLOG][POST] ❌ Coordinate timeout after retries', + ); + return null; + }, + ); + + if (!mounted) { + debugPrint('[CART][DELIVERYLOG][POST] Widget unmounted after coords'); + return; + } + + // CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates + if (coords == null || + coords.$1.isEmpty || + coords.$2.isEmpty || + coords.$1 == '0' || + coords.$2 == '0') { + debugPrint( + '[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})', + ); + debugPrint( + '[CART][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)', + ); + return; // Skip this post - don't send invalid coordinates + } + + // Validate coordinate ranges + final latDouble = double.tryParse(coords.$1) ?? 0.0; + final lngDouble = double.tryParse(coords.$2) ?? 0.0; + if (latDouble == 0 || + lngDouble == 0 || + latDouble.abs() > 90 || + lngDouble.abs() > 180) { + debugPrint( + '[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)', + ); + debugPrint( + '[CART][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)', + ); + return; // Skip this post - don't send invalid coordinates + } + + debugPrint( + '[CART][DELIVERYLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}', + ); + + // Cumulative KM is tracked exclusively by LiveTrackingService (high-frequency, every 3s). + // Do not accumulate here to avoid race conditions with concurrent writers. + + final now = DateTime.now(); + final logdate = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + + // CRITICAL: Ensure starttime is always included in payload + final starttimeValue = basePayload['starttime']?.toString() ?? ''; + if (starttimeValue.isEmpty) { + debugPrint( + '[CART][DELIVERYLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback', + ); + } + + // CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point + final payload = { + ...basePayload, + 'logdate': logdate, + 'latitude': coords.$1, // Guaranteed non-null and valid + 'longitude': coords.$2, // Guaranteed non-null and valid + 'starttime': starttimeValue.isNotEmpty + ? starttimeValue + : '', // CRITICAL: Always include starttime + }; + + // Validate payload has all required fields + final requiredFields = [ + 'tenantid', + 'partnerid', + 'locationid', + 'orderheaderid', + 'deliveryid', + 'userid', + 'orderid', + 'orderstatus', + 'starttime', + ]; + final missingFields = requiredFields + .where((field) => payload[field] == null || payload[field] == '') + .toList(); + if (missingFields.isNotEmpty) { + debugPrint( + '[CART][DELIVERYLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields', + ); + } + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + debugPrint('[CART][DELIVERYLOG][POST] 📤 Sending to API: $url'); + debugPrint('[CART][DELIVERYLOG][POST] 📦 Payload: $payload'); + debugPrint( + '[CART][DELIVERYLOG][POST] ✅ starttime in payload: "${payload['starttime']}"', + ); + + await _deliveryLogProvider + .createDeliveryLog(url, payload) + .timeout( + const Duration(seconds: 8), + onTimeout: () { + debugPrint( + '[CART][DELIVERYLOG][POST] ⚠️ API timeout for orderId: $orderId', + ); + throw TimeoutException('API timeout', const Duration(seconds: 8)); + }, + ); + + debugPrint( + '[CART][DELIVERYLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}', + ); + } catch (e, stackTrace) { + debugPrint( + '[CART][DELIVERYLOG][POST] ❌ ERROR for orderId: $orderId - $e', + ); + debugPrint('[CART][DELIVERYLOG][POST] Stack trace: $stackTrace'); + } + } + + Future<(String lat, String lng)?> _getValidCoordinates({ + int retryCount = 0, + }) async { + const maxRetries = 3; + + try { + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint( + '[CART][COORDS] Location service disabled, trying last known position', + ); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && + lastPos.latitude != 0 && + lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint('[CART][COORDS] ✅ Using last known position: $lat, $lng'); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + debugPrint( + '[CART][COORDS] Permission denied, trying last known position', + ); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && + lastPos.latitude != 0 && + lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint('[CART][COORDS] ✅ Using last known position: $lat, $lng'); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + Position? position; + try { + // Try to get current position with higher accuracy + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: + LocationAccuracy.high, // Changed to high for better accuracy + timeLimit: Duration(seconds: 8), // Increased timeout + ), + ).timeout(const Duration(seconds: 8)); + } catch (e) { + debugPrint( + '[CART][COORDS] Timeout getting current position: $e, trying last known', + ); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null && + position.latitude != 0 && + position.longitude != 0) { + final lat = position.latitude.toString(); + final lng = position.longitude.toString(); + + // Validate coordinates are within valid GPS ranges + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + debugPrint('[CART][COORDS] ✅ Got valid coordinates: $lat, $lng'); + return (lat, lng); + } else { + debugPrint('[CART][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng'); + } + } + + // Fallback to SharedPreferences cached coordinates + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') { + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble != 0 && + lngDouble != 0 && + latDouble.abs() <= 90 && + lngDouble.abs() <= 180) { + debugPrint('[CART][COORDS] ✅ Using cached coordinates: $lat, $lng'); + return (lat, lng); + } + } + } catch (_) {} + + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + debugPrint( + '[CART][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates', + ); + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + + debugPrint( + '[CART][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries', + ); + return null; + } catch (e) { + debugPrint('[CART][COORDS] ❌ Error getting coordinates: $e'); + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + } + + void _stopDeliveryPosting(String orderId) { + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + _deliveryBasePayload.remove(orderId); + debugPrint('[CART][DELIVERYLOG] Stopped timer for orderId: $orderId'); + } + + void _stopAllTimers() { + for (final timer in _deliveryTimers.values) { + timer.cancel(); + } + _deliveryTimers.clear(); + _deliveryBasePayload.clear(); + debugPrint('[CART][DELIVERYLOG] Stopped all timers'); + } + + // Method to stop delivery posting for a specific order (called when order is completed) + void stopDeliveryPostingForOrder(String orderId) { + _stopDeliveryPosting(orderId); + // Refresh the list to remove completed orders + if (mounted) { + _fetchActive(); + } + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + double _haversineKm(double lat1, double lon1, double lat2, double lon2) { + const double R = 6371.0; + final double dLat = _toRadians(lat2 - lat1); + final double dLon = _toRadians(lon2 - lon1); + final double a = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + return R * c; + } + + double _toRadians(double degrees) { + return degrees * math.pi / 180.0; + } + + String _distanceKmDisplay(Map m) { + // Try API distance first + final String apiKmsStr = (m['actualkms'] ?? m['distance'] ?? '0') + .toString(); + final double apiKms = double.tryParse(apiKmsStr) ?? 0.0; + if (apiKms > 0) { + return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0); + } + + // Try rider location to delivery location + final double rLat = _parseD(m['riderslat']); + final double rLon = _parseD(m['riderslon']); + final double dLat = _parseD(m['droplat'] ?? m['deliverylat']); + final double dLon = _parseD(m['droplon'] ?? m['deliverylong']); + + if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(rLat, rLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + // Fallback: pickup to delivery + final double pLat = _parseD(m['pickuplat']); + final double pLon = _parseD(m['pickuplon']); + if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(pLat, pLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + return '0'; + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + centerTitle: false, + toolbarHeight: 70, + title: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + "Active Deliveries", + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ), + bottom: const PreferredSize( + preferredSize: Size.fromHeight(1), + child: Divider(height: 1, color: Colors.grey), + ), + ), + body: SafeArea( + child: _activeDeliveries.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 80), + Transform.translate( + offset: const Offset(0, -11), + child: Image.asset( + "assets/images/Doormile Bike.png", + errorBuilder: (c, e, s) => const Icon( + Icons.delivery_dining, + size: 120, + color: Colors.grey, + ), + ), + ), + const SizedBox(height: 16), + Transform.translate( + offset: const Offset(0, -11), + child: Text( + "No Active Deliveries", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontFamily: FontConstants.fontFamily, + color: Colors.grey, + ), + ), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _fetchActive, + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 20), + itemCount: _activeDeliveries.length, + itemBuilder: (context, index) { + final item = _activeDeliveries[index]; + final customerName = + (item['deliverycustomer'] ?? + item['customer'] ?? + 'Customer') + .toString(); + final address = + (item['deliveryaddress'] ?? + item['address'] ?? + 'Address not available') + .toString(); + final storeName = + (item['pickupcustomer'] ?? item['store'] ?? 'Store') + .toString(); + final orderId = (item['orderid'] ?? '').toString(); + final distance = _distanceKmDisplay(item); + + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ------------------------------------------- + // TOP CUSTOMER DETAILS + // ------------------------------------------- + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + ), + Container( + width: 2, + height: 30, + color: Colors.grey.shade300, + ), + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: const Offset(0, -3), + child: Text( + customerName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: + FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Transform.translate( + offset: const Offset(0, 8), + child: Text( + address, + style: TextStyle( + fontSize: 18, + color: Colors.black87, + fontFamily: + FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Distance: $distance km', + style: TextStyle( + fontSize: 16, + color: Colors.blueGrey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + InkWell( + onTap: () async { + // Just launch dialer; PiP is handled only from navigation screen + final phone = + (item['deliverycontactno'] ?? '') + .toString(); + final bool success = + await launchPhoneDialer( + phone.isNotEmpty + ? phone + : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Could not launch dialer', + ), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => const Icon( + Icons.phone, + size: 27, + color: Colors.green, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + // ------------------------------------------- + // STORE DETAILS + // ------------------------------------------- + Row( + children: [ + Image.asset( + 'assets/images/shoppingbag.png', + height: 32, + width: 32, + errorBuilder: (c, e, s) => const Icon( + Icons.shopping_bag, + size: 32, + color: Colors.orange, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + storeName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: + FontConstants.fontFamily, + ), + ), + InkWell( + onTap: () async { + _showMyOptionsSheet( + context, + item, + null, // No parent state for cart page + ); + // Refresh cart page after skip (order will no longer be active) + if (mounted) { + await Future.delayed( + const Duration(seconds: 1), + ); + _fetchActive(); + } + }, + child: Transform.translate( + offset: const Offset(0, -5), + child: Text( + 'Skip>>', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: + FontConstants.fontFamily, + color: ColorConstants + .primaryColor, + ), + ), + ), + ), + ], + ), + Text( + "Order ID: #$orderId", + style: TextStyle( + fontSize: 18, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 15), + // ------------------------------------------- + // SLIDER BUTTON + // ------------------------------------------- + SliderButton( + properties: SliderButtonProperties( + height: 50, + buttonSize: 45, + width: MediaQuery.of(context).size.width - 56, + backgroundColor: ColorConstants.primaryColor, + dismissThresholds: 0.90, + action: () async { + await Future.delayed( + const Duration(milliseconds: 400), + ); + if (!context.mounted) return false; + + // Navigate to delivery map screen (same as deliveries page) + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _DeliveryMapScreen( + delivery: item, + parentState: + null, // No parent state for cart page + ), + ), + ); + + // Refresh cart page when returning from map screen + // (in case delivery was completed/cancelled) + if (mounted) { + await Future.delayed( + const Duration(milliseconds: 500), + ); + _fetchActive(); + } + + return false; + }, + label: const Text( + 'Slide to start Delivery', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + icon: ClipOval( + child: Material( + color: Colors.white, + child: SizedBox( + width: 45, + height: 45, + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/card.dart b/lib/xpress/views/Dashboard/deliveries/card.dart new file mode 100644 index 0000000..a8771f0 --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/card.dart @@ -0,0 +1,329 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// DELIVERY CARD +// ------------------------------------------------------------------------- +class DeliveryCard extends StatelessWidget { + final Map item; + final int displayStep; + final String distanceStr; + final bool enabled; + final bool isSkipped; + + const DeliveryCard({ + super.key, + required this.item, + required this.displayStep, + required this.distanceStr, + this.enabled = true, + this.isSkipped = false, + }); + + @override + Widget build(BuildContext context) { + final String customerName = (item['deliverycustomer'] ?? 'Customer') + .toString(); + final String address = (item['deliveryaddress'] ?? 'Address not available') + .toString(); + final String tenantName = (item['tenantname'] ?? 'Store').toString(); + final String orderId = (item['orderid'] ?? '').toString(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: isSkipped ? Border.all(color: Colors.orange, width: 2) : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isSkipped) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'SKIPPED', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.orange.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (isSkipped) const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + ), + Container( + width: 2, + height: 30, + color: Colors.grey.shade300, + ), + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: const Offset(0, -3), + child: Text( + customerName, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 17.5, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Transform.translate( + offset: const Offset(0, 8), + child: Text( + address, + style: TextStyle( + fontSize: 18, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Distance: $distanceStr km', + style: TextStyle( + fontSize: 17, + color: Colors.blueGrey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + InkWell( + onTap: () async { + // Just launch dialer; PiP is handled only from navigation screen + final phone = (item['deliverycontactno'] ?? '').toString(); + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => + const Icon(Icons.phone, size: 27, color: Colors.green), + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + Row( + children: [ + Image.asset( + 'assets/images/shoppingbag.png', + height: 32, + width: 32, + errorBuilder: (c, e, s) => const Icon( + Icons.shopping_bag, + size: 32, + color: Colors.orange, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + tenantName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + if (!isSkipped) + InkWell( + onTap: () { + final parentState = context + .findAncestorStateOfType< + _MyDeliveriesState + >(); + if (parentState != null) { + _showMyOptionsSheet( + context, + item, + parentState, + ); + } + }, + child: Transform.translate( + offset: const Offset(0, -5), + child: Text( + 'Skip>>', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + ), + ), + ], + ), + Text( + 'Order ID: #$orderId', + style: TextStyle( + fontSize: 18, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 15), + SliderButton( + properties: SliderButtonProperties( + height: 50, + buttonSize: 45, + width: MediaQuery.of(context).size.width - 56, + backgroundColor: enabled + ? ColorConstants.primaryColor + : Colors.grey.shade400, + dismissThresholds: 0.90, + action: enabled + ? () async { + // Reduce delay to make it feel snappier + await Future.delayed(const Duration(milliseconds: 50)); + if (!context.mounted) return false; + final parentState = context + .findAncestorStateOfType<_MyDeliveriesState>(); + + // ✅ BLOCK: Check if there's already an active delivery (and this isn't it) + if (parentState != null) { + final currentOrderId = (item['orderid'] ?? '') + .toString(); + final activeOrderIds = parentState._activeDeliveries + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Block if there's a different active delivery: just ignore the swipe + if (parentState._activeDeliveries.isNotEmpty && + !activeOrderIds.contains(currentOrderId)) { + return false; + } + } + + // Navigate to delivery map screen + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _DeliveryMapScreen( + delivery: item, + parentState: parentState, + ), + ), + ); + } + return false; + } + : () async => null, + label: Transform.translate( + offset: const Offset(-0.5, 0), + child: Text( + isSkipped + ? 'Slide to resume Delivery' + : (enabled + ? 'Slide to start Delivery' + : 'Complete previous delivery'), + style: const TextStyle( + fontSize: 18.5, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + icon: ClipOval( + child: Material( + color: Colors.white, + child: SizedBox( + width: 45, + height: 45, + child: Center( + child: Text( + '$displayStep', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: enabled + ? const ui.Color.fromARGB(255, 0, 0, 0) + : Colors.grey, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/deliveries.dart b/lib/xpress/views/Dashboard/deliveries/deliveries.dart new file mode 100644 index 0000000..33f5013 --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/deliveries.dart @@ -0,0 +1,1761 @@ +// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:get/get.dart'; +import 'package:lottie/lottie.dart' hide Marker; +import 'package:miler/data/route_order.dart'; +import 'package:miler/xpress/services/maps/map_marker_icons.dart'; +import 'package:miler/xpress/services/maps/doormile_map.dart'; +import 'package:miler/xpress/services/maps/routing_service.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart'; +import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:miler/xpress/providers/delivery/delivery_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/controllers/deliveries_controller.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:miler/xpress/widget/Bottom_page.dart'; +import 'package:circular_countdown_timer/circular_countdown_timer.dart'; +import 'dart:math' as math; +import 'package:miler/xpress/controllers/riderlog.dart'; +import 'package:miler/xpress/providers/deliverylog/deliverylog_provider.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'dart:io' show Platform, File; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:floating/floating.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:http/http.dart' as http; +import 'package:scratcher/scratcher.dart'; +import 'package:confetti/confetti.dart'; +import 'package:miler/xpress/data/delivery_api.dart'; + +part 'card.dart'; +part 'map.dart'; +part 'nav.dart'; +part 'pip.dart'; +part 'sheet.dart'; +part 'map_btn.dart'; +part 'multi_map.dart'; +part 'done.dart'; +part 'skip_sheet.dart'; +part '../Cart/cartpage.dart'; + +/// Helper function to launch phone dialer - works in both debug and release builds +/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch +Future launchPhoneDialer(String phoneNumber) async { + try { + // Sanitize phone number: keep only digits and '+' + final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), ''); + + if (phone.isEmpty) { + debugPrint( + '[PHONE] Empty phone number after sanitization, skipping dial', + ); + return false; + } + + final Uri uri = Uri(scheme: 'tel', path: phone); + + // Try canLaunchUrl first (works in debug, may fail in release) + bool canLaunch = false; + try { + canLaunch = await canLaunchUrl(uri); + debugPrint('[PHONE] canLaunchUrl result: $canLaunch'); + } catch (e) { + debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e'); + // Continue anyway - launch might still work + } + + // Always attempt to launch, even if canLaunchUrl returned false + // Using LaunchMode.platformDefault is often safer for system intents like dialing + try { + final launched = await launchUrl(uri, mode: LaunchMode.platformDefault); + if (launched) { + debugPrint('[PHONE] Successfully launched dialer for: $phone'); + return true; + } else { + debugPrint('[PHONE] launchUrl returned false for: $phone'); + } + } catch (e) { + debugPrint('[PHONE] Failed to launch dialer: $e'); + } + return false; + } catch (e) { + debugPrint('[PHONE] Error in launchPhoneDialer: $e'); + return false; + } +} + +class MyDeliveries extends StatefulWidget { + const MyDeliveries({super.key}); + + @override + State createState() => _MyDeliveriesState(); + + // ✅ Public static method to navigate to delivery map screen from outside (e.g., home page) + static Future navigateToDeliveryMap( + BuildContext context, + Map delivery, + ) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _RiderNavigationScreen( + delivery: delivery, + parentState: null, // No parent state when called from outside + ), + ), + ); + } +} + +class _MyDeliveriesState extends State + with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { + final DeliveryProvider _provider = DeliveryProvider(); + final CreateDeliveryLogProvider _deliveryLogProvider = + CreateDeliveryLogProvider(); + List> _picked = >[]; + List> _activeDeliveries = + >[]; // Track active deliveries for banner + + StreamSubscription? _pollerSubscription; + bool _fetching = false; + Position? _currentLocation; + final Map _deliveryTimers = {}; + final Map> _deliveryBasePayload = + >{}; + // Preserve original step numbers so they don't change when deliveries are completed + final Map _preservedStepNumbers = {}; + String? _activeDeliveryOrderId; + // Cache for skipped orders + final Map _skippedOrdersCache = {}; + final Map _skippedOrderTimestamps = {}; + + Future _saveSkippedOrdersCache() async { + // Implementation can be empty if we rely on API now, + // or strictly local. For now, we'll keep it simple or empty + // to satisfy the interface expected by child widgets. + // If child widgets call this, they expect it to exist. + } + + @override + bool get wantKeepAlive => true; + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + double _haversineKm(double lat1, double lon1, double lat2, double lon2) { + const double R = 6371.0; + final double dLat = _toRadians(lat2 - lat1); + final double dLon = _toRadians(lon2 - lon1); + + final double a = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + + final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + return R * c; + } + + double _toRadians(double degrees) { + return degrees * math.pi / 180.0; + } + + int _getStepNumber(Map order) { + final dynamic raw = order['step'] ?? order['Step']; + final int step = raw == null + ? 0 + : (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0); + return step; + } + + String _getOrderKey(Map order) { + // Use deliveryId as primary key, fallback to orderId + final deliveryId = (order['deliveryid'] ?? '').toString(); + final orderId = (order['orderid'] ?? '').toString(); + return deliveryId.isNotEmpty ? 'delivery_$deliveryId' : 'order_$orderId'; + } + + int _getPreservedOrCurrentStep(Map order) { + final orderKey = _getOrderKey(order); + // If we have a preserved step number, use it; otherwise use current API step + if (_preservedStepNumbers.containsKey(orderKey)) { + return _preservedStepNumbers[orderKey]!; + } + final currentStep = _getStepNumber(order); + // Preserve the step number if it's valid (> 0) + if (currentStep > 0) { + _preservedStepNumbers[orderKey] = currentStep; + } + return currentStep; + } + + String _distanceKmDisplay(Map m) { + final String apiKmsStr = (m['kms'] ?? '').toString().trim(); + final double apiKms = double.tryParse(apiKmsStr) ?? 0.0; + if (apiKms > 0) { + return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0); + } + + final double rLat = _parseD(m['riderslat']); + final double rLon = _parseD(m['riderslon']); + final double dLat = _parseD(m['droplat'] ?? m['deliverylat']); + final double dLon = _parseD(m['droplon'] ?? m['deliverylong']); + + if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(rLat, rLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + final double pLat = _parseD(m['pickuplat']); + final double pLon = _parseD(m['pickuplon']); + if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(pLat, pLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + return '0'; + } + + double _calculateDistanceFromCurrent(Map order) { + if (_currentLocation == null) return double.infinity; + + final double dropLat = _parseD(order['droplat'] ?? order['deliverylat']); + final double dropLon = _parseD(order['droplon'] ?? order['deliverylong']); + + if (dropLat == 0 || dropLon == 0) return double.infinity; + + return _haversineKm( + _currentLocation!.latitude, + _currentLocation!.longitude, + dropLat, + dropLon, + ); + } + + /// The same rule the Doormile lane follows — see [RouteOrder]. + /// + /// This was the second copy of the split sort: sequenced stops by `step`, + /// the rest by distance from the rider, concatenated. Two riders on two + /// tenants could be handed the same assigned route and work it in two + /// different orders, and neither list was the one the hub had solved. + List> _sortOrders(List> orders) { + if (orders.isEmpty) return orders; + + final (ordered, source) = RouteOrder.sort( + orders, + distanceTo: _currentLocation == null + ? null + : (o) => _calculateDistanceFromCurrent(o), + ); + if (!source.isAdmin) { + RouteOrder.logUnsequenced('xpress-deliveries', orders.length); + } + return ordered; + } + + int _getDisplayStepNumber(List> allOrders, int index) { + final order = allOrders[index]; + // Use preserved step number if available, otherwise get current step + final step = _getPreservedOrCurrentStep(order); + + if (step > 0) return step; + + // For orders without step numbers, calculate based on orders that have step numbers + final ordersWithStep = allOrders + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + return ordersWithStep + (index - ordersWithStep) + 1; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WakelockPlus.enable(); + + // Listen for external refresh triggers + try { + final dc = Get.isRegistered() + ? Get.find() + : Get.put(DeliveriesController()); + + ever(dc.refreshTrigger, (_) { + if (mounted) { + debugPrint( + '[MYDELIVERIES] External trigger -> Refreshing picked orders', + ); + _fetchPicked(); + } + }); + } catch (_) {} + + // Run these in parallel + _restoreActiveDelivery(); + _initializeLocation(); // Don't block + _fetchPicked(); + _startPollingStream(); + } + + Future _restoreActiveDelivery() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedActiveId = prefs.getString('active_delivery_order_id'); + if (savedActiveId != null && savedActiveId.isNotEmpty) { + _activeDeliveryOrderId = savedActiveId; + debugPrint( + '[MYDELIVERIES] Restored active delivery orderId: $savedActiveId', + ); + } + } catch (e) { + debugPrint('[MYDELIVERIES] Error restoring active delivery: $e'); + } + } + + void _startPollingStream() { + _pollerSubscription?.cancel(); + _pollerSubscription = Stream.periodic(const Duration(seconds: 3), (_) {}) + .asyncMap((_) async { + if (mounted && !_fetching) { + await _fetchPicked(); + } + }) + .listen( + (_) {}, // Success handler + onError: (error) { + // Handle errors gracefully without crashing + debugPrint('[MYDELIVERIES][STREAM ERROR] $error'); + }, + cancelOnError: false, // Continue even on errors + ); + } + + Future _initializeLocation() async { + try { + // 1. Try Last Known Position (Instant & Preferred) + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + _currentLocation = lastPos; + }); + // If we have a cached location, don't block waiting for fresh GPS + // We can let the background stream update it later + return; + } + } catch (_) {} + + // 2. Try Current Position (Optimized) + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + return; + } + + // Single attempt with balanced accuracy/timeout + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 3), + ), + ); + + if (mounted) { + setState(() => _currentLocation = position); + } + } catch (_) { + // Fallback to low accuracy if high fails + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 2), + ), + ); + if (mounted) { + setState(() => _currentLocation = position); + } + } catch (_) {} + } + } catch (e) { + debugPrint('[MYDELIVERIES] Error initializing location: $e'); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _pollerSubscription?.cancel(); + for (final timer in _deliveryTimers.values) { + timer.cancel(); + } + _deliveryTimers.clear(); + _deliveryBasePayload.clear(); + // Persist current live deliveries state for other screens + SharedPreferences.getInstance() + .then((p) => p.setBool('has_live_deliveries', _picked.isNotEmpty)) + // ignore: body_might_complete_normally_catch_error + .catchError((_) {}); + WakelockPlus.disable(); + super.dispose(); + } + + bool _shallowMapEquals(Map a, Map b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (b[entry.key] != entry.value) { + return false; + } + } + return true; + } + + // Helper to check if two lists of orders are effectively equal + // This prevents unnecessary rebuilds when polling + bool _areOrdersEqual( + List> oldList, + List> newList, + ) { + if (oldList.length != newList.length) return false; + + for (int i = 0; i < oldList.length; i++) { + final oldItem = oldList[i]; + final newItem = newList[i]; + + // Compare critical fields that affect UI or logic + if (oldItem['orderid'] != newItem['orderid']) return false; + if (oldItem['orderstatus'] != newItem['orderstatus']) return false; + if (oldItem['step'] != newItem['step']) return false; + + // Compare location data (important for map updates) + if (oldItem['riderslat'] != newItem['riderslat']) return false; + if (oldItem['riderslon'] != newItem['riderslon']) return false; + if (oldItem['deliverylat'] != newItem['deliverylat']) return false; + if (oldItem['deliverylong'] != newItem['deliverylong']) return false; + + // Compare notes/instructions if they might change + if (oldItem['notes'] != newItem['notes']) return false; + } + + return true; + } + + // Manually mark skipped to trigger immediate refresh + void markOrderAsSkipped(Map order, String reason) { + if (!mounted) return; + debugPrint('[MYDELIVERIES] Mark skipped -> Refreshing from API...'); + // We don't update local state manually anymore, we trust the API to return the 'skipped' status. + // Just trigger a fetch. + _fetchPicked(); + } + + // ✅ Check if there's an active delivery (blocks all actions) + bool _hasActiveDelivery() { + return _activeDeliveries.isNotEmpty; + } + + // Previously showed a blocking dialog; now we just disable conflicting buttons in the UI. + void _showActiveDeliveryBlockMessage() {} + + Future startDelivery(Map item) async { + if (!mounted) return; + + // ✅ BLOCK: Check if there's already an active delivery (and this isn't it) + final currentOrderId = (item['orderid'] ?? '').toString(); + final activeOrderIds = _activeDeliveries + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Allow if this IS the active delivery, block if there's a different active delivery + if (_hasActiveDelivery() && !activeOrderIds.contains(currentOrderId)) { + // Just ignore taps on other cards when a different active delivery exists. + return; + } + + // Verify status is updated to ACTIVE before navigating + final dc = Get.put(DeliveriesController()); + final dId = int.tryParse((item['deliveryid'] ?? 0).toString()) ?? 0; + final ohId = int.tryParse((item['orderheaderid'] ?? 0).toString()) ?? 0; + + if (dId > 0 && ohId > 0) { + // We don't block navigation on failure, but we try to update + // This ensures 'starttime' is generated and saved + await dc.updateActiveStatus( + deliveryId: dId, + orderHeaderId: ohId, + orderId: (item['orderid'] ?? '').toString(), + ); + } + + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + _RiderNavigationScreen(delivery: item, parentState: this), + ), + ); + } + + Future resumeDelivery(Map item) async { + // ✅ BLOCK: Check if there's already an active delivery (and this isn't it) + final currentOrderId = (item['orderid'] ?? '').toString(); + final activeOrderIds = _activeDeliveries + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Allow if this IS the active delivery, block if there's a different active delivery + if (_hasActiveDelivery() && !activeOrderIds.contains(currentOrderId)) { + // Ignore resume taps when some other delivery is active. + return; + } + + // For now, resume behaves same as start (navigates to nav screen) + // You can add specific resume logic here if needed (e.g. un-skip) + await startDelivery(item); + } + + Future _fetchPicked() async { + if (_fetching) return; + + _fetching = true; + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; + + // ✅ CRITICAL: Fetch from v3 API (picked orders) AND v2 API (all statuses) IN PARALLEL + + // Define V2 fetcher function + Future> fetchV2() async { + try { + final now = DateTime.now(); + final today = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + // One Doormile route now — see [DeliveryApi]. + + final baseUrl = DeliveryApi.currentDeliveries; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + final httpClient = http.Client(); + try { + final response = await httpClient.get(uri); + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + return data is List + ? data + : (data is Map && data['items'] is List + ? data['items'] as List + : []); + } + } finally { + httpClient.close(); + } + } catch (e) { + debugPrint('[MYDELIVERIES] Error fetching from v2 API: $e'); + } + return []; + } + + // Execute in parallel + final results = await Future.wait([ + _provider.getDeliveryQueuesPicked(live: true, userid: userId), + fetchV2(), + ]); + + final itemsV3 = results[0] as List; + final itemsV2 = results[1] as List; + + // Merge v3 orders (picked) with v2 orders (all statuses) + final Map> mergedOrders = {}; + + // First add v3 orders (picked) + for (final order in itemsV3.whereType>()) { + final key = _getOrderKey(order); + mergedOrders[key] = order; + } + + // Then add/update with v2 orders - V2 STATUS TAKES PRECEDENCE + for (final order in itemsV2.whereType>()) { + final key = _getOrderKey(order); + final existing = mergedOrders[key]; + + if (existing == null) { + mergedOrders[key] = order; + } else { + // If it exists in V3 (picked) but V2 says something else, trust V2. + // Especially for 'skipped', 'delivered', 'cancelled'. + mergedOrders[key] = { + ...existing, + ...order, // Overwrite with V2 data + }; + } + } + + final items = mergedOrders.values.toList(); + debugPrint('[MYDELIVERIES] Merged items count: ${items.length}'); + + final dedupedList = items; // Already deduped by key map + + // Preserve step numbers for existing orders + for (final order in dedupedList) { + final orderKey = _getOrderKey(order); + final currentStep = _getStepNumber(order); + if (currentStep > 0 && !_preservedStepNumbers.containsKey(orderKey)) { + _preservedStepNumbers[orderKey] = currentStep; + } + } + + // Sort the list + final sortedList = _sortOrders(dedupedList); + + // Filter out ACTIVE orders from top list (they go to banner) + final listForTop = sortedList.where((o) { + final status = (o['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + return status != 'active'; + }).toList(); + + final skippedInFinal = listForTop.where((m) { + final status = (m['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + return status == 'skipped'; + }).length; + + debugPrint( + '[MYDELIVERIES] Final top list: ${listForTop.length} (skipped: $skippedInFinal)', + ); + + // Check if data has actually changed before rebuilding + // This avoids unnecessary setState calls during polling + final bool hasChanged = !_areOrdersEqual(_picked, listForTop); + + if (hasChanged) { + if (mounted) { + setState(() { + _picked = listForTop; + }); + debugPrint( + '[MYDELIVERIES] 🔄 UI Updated: ${listForTop.length} orders ($skippedInFinal skipped)', + ); + } + } else { + // No changes + } + + // Get all order IDs from the current list + final currentOrderIds = sortedList + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ Find all ACTIVE deliveries and ensure timers are running + final activeOrders = sortedList.where((o) { + final status = (o['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + return status == 'active'; + }).toList(); + + debugPrint( + '[MYDELIVERIES] Found ${activeOrders.length} active deliveries', + ); + + // ✅ CRITICAL: Only set has_live_deliveries to true if there are ACTIVE orders (status = "active") + // Don't set it to true for "picked" or other statuses - only for "active" + try { + final hasActiveOrders = activeOrders.isNotEmpty; + await prefs.setBool('has_live_deliveries', hasActiveOrders); + debugPrint( + '[MYDELIVERIES] Set has_live_deliveries: $hasActiveOrders (${activeOrders.length} active orders)', + ); + + // Clear active_delivery_order_id if no active deliveries + if (!hasActiveOrders) { + final activeOrderId = prefs.getString('active_delivery_order_id'); + if (activeOrderId != null && activeOrderId.isNotEmpty) { + debugPrint( + '[MYDELIVERIES] Clearing stale active_delivery_order_id: $activeOrderId (no active deliveries)', + ); + await prefs.remove('active_delivery_order_id'); + } + } + } catch (_) {} + + // Update active deliveries list for banner display + if (mounted) { + setState(() { + _activeDeliveries = activeOrders; + }); + } + + // Get set of active order IDs + final activeOrderIds = activeOrders + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ CRITICAL: Start timers ONLY for active deliveries that don't have one yet + // This ensures logs are posted every 30 seconds WITHOUT restarting timers on every fetch + for (final order in activeOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + // ✅ ONLY start timer if it doesn't already exist + // This prevents restarting timers on every _fetchPicked() call (which happens frequently) + if (!_deliveryTimers.containsKey(orderId)) { + debugPrint( + '[MYDELIVERIES] Active delivery: $orderId - Starting delivery log posting (timer not found)', + ); + // ✅ RE-ENABLED: Delivery logs now posted from deliveries page with robust validation + await _startDeliveryPosting(order); + } else { + debugPrint( + '[MYDELIVERIES] Active delivery: $orderId - Timer already running, skipping restart', + ); + } + + _activeDeliveryOrderId = orderId; // Track the active delivery + + // Persist active delivery ID so it survives app restarts + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('active_delivery_order_id', orderId); + } catch (e) { + debugPrint('[MYDELIVERIES] Error saving active delivery ID: $e'); + } + } + + // ✅ Stop timers ONLY for deliveries that are no longer active or in the list + final timersToStop = _deliveryTimers.keys + .where( + (id) => + !activeOrderIds.contains(id) || !currentOrderIds.contains(id), + ) + .toList(); + + for (final id in timersToStop) { + debugPrint( + '[MYDELIVERIES] Stopping timer for orderId: $id (no longer active)', + ); + _stopDeliveryPosting(id); + } + + debugPrint( + '[MYDELIVERIES] Active timers: ${_deliveryTimers.keys.toList()}', + ); + debugPrint( + '[MYDELIVERIES] Active delivery orderId: $_activeDeliveryOrderId', + ); + } catch (e) { + debugPrint('[MYDELIVERIES] Error fetching picked orders: $e'); + } finally { + _fetching = false; + } + } + + // ignore: unused_element + Map _createBasePayload(Map it) { + return { + 'logid': 0, + 'tenantid': it['tenantid'] ?? 0, + 'partnerid': it['partnerid'] ?? 0, + 'locationid': it['locationid'] ?? 0, + 'orderheaderid': it['orderheaderid'] ?? 0, + 'deliveryid': it['deliveryid'] ?? 0, + 'userid': it['userid'] ?? 0, + 'orderid': (it['orderid'] ?? '').toString(), + 'orderstatus': 'active', + }; + } + + // Stop posting logs for a specific delivery + void _stopDeliveryPosting(String orderId) { + final timer = _deliveryTimers.remove(orderId); + if (timer != null) { + timer.cancel(); + debugPrint('[DELIVERYLOG] Stopped posting logs for orderId: $orderId'); + } else { + debugPrint('[DELIVERYLOG] No timer to stop for orderId: $orderId'); + } + } + + Future<(String lat, String lng)?> _getValidCoordinates({ + int retryCount = 0, + }) async { + const maxRetries = 3; + + try { + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint( + '[DELIVERIES][COORDS] Location service disabled, trying last known position', + ); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && + lastPos.latitude != 0 && + lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint( + '[DELIVERIES][COORDS] ✅ Using last known position: $lat, $lng', + ); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + debugPrint( + '[DELIVERIES][COORDS] Permission denied, trying last known position', + ); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && + lastPos.latitude != 0 && + lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint( + '[DELIVERIES][COORDS] ✅ Using last known position: $lat, $lng', + ); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + Position? position; + try { + // Try to get current position with higher accuracy + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: + LocationAccuracy.high, // Changed to high for better accuracy + timeLimit: Duration(seconds: 8), // Increased timeout + ), + ).timeout(const Duration(seconds: 8)); + } catch (e) { + debugPrint( + '[DELIVERIES][COORDS] Timeout getting current position: $e, trying last known', + ); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null && + position.latitude != 0 && + position.longitude != 0) { + final lat = position.latitude.toString(); + final lng = position.longitude.toString(); + + // Validate coordinates are within valid GPS ranges + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + debugPrint( + '[DELIVERIES][COORDS] ✅ Got valid coordinates: $lat, $lng', + ); + return (lat, lng); + } else { + debugPrint( + '[DELIVERIES][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng', + ); + } + } + + // Fallback to SharedPreferences cached coordinates + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') { + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble != 0 && + lngDouble != 0 && + latDouble.abs() <= 90 && + lngDouble.abs() <= 180) { + debugPrint( + '[DELIVERIES][COORDS] ✅ Using cached coordinates: $lat, $lng', + ); + return (lat, lng); + } + } + } catch (_) {} + + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + debugPrint( + '[DELIVERIES][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates', + ); + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + + debugPrint( + '[DELIVERIES][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries', + ); + return null; + } catch (e) { + debugPrint('[DELIVERIES][COORDS] ❌ Error getting coordinates: $e'); + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + } + + Future _startDeliveryPosting(Map order) async { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Cannot start timer: empty orderId', + ); + return; + } + + // Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above) + if (_deliveryTimers.containsKey(orderId)) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Timer already exists for $orderId, canceling old one', + ); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId', + ); + + debugPrint( + '[DELIVERIES][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId', + ); + + // REMOVED: Do not reset cumulative distance here. + // It is already reset in DeliveriesController.updateActiveStatus when the status actually changes. + // Resetting here causes data loss if the app is restarted while a delivery is in progress. + + // START FOREGROUND SERVICE PROTECTION + // Ensure the RiderLogController knows we are active so the Foreground Service stays alive + // This protects THIS timer from being killed by the OS + try { + if (Get.isRegistered()) { + final riderLog = Get.find(); + debugPrint( + '[DELIVERIES][DELIVERYLOG] 🛡️ Activating Foreground Service via RiderLogController...', + ); + // Force 30s interval to match delivery logging + riderLog.startAutoCreateLoginLoop(seconds: 30); + } + } catch (e) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Could not start Foreground Service: $e', + ); + } + + final deliveryId = (order['deliveryid'] ?? 0).toString(); + + // Get starttime from SharedPreferences (saved when order became active via updateActiveStatus) + // If not found, use activetime from order data, or current time as fallback + String startTime = ''; + try { + final prefs = await SharedPreferences.getInstance(); + + // Method 1: Get from SharedPreferences (saved when order became active) + startTime = prefs.getString('delivery_starttime_$deliveryId') ?? ''; + if (startTime.isNotEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from SharedPreferences: $startTime', + ); + } + + // Method 2: Fallback - try to get from order data (starttime field) + if (startTime.isEmpty) { + startTime = (order['starttime'] ?? order['startTime'] ?? '').toString(); + if (startTime.isNotEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from order data: $startTime', + ); + } + } + + // Method 3: Fallback - try activetime from order data + if (startTime.isEmpty) { + final activetime = (order['activetime'] ?? order['activTime'] ?? '') + .toString(); + if (activetime.isNotEmpty) { + startTime = activetime; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from activetime: $startTime', + ); + } + } + + // Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called) + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Using current time as starttime fallback: $startTime', + ); + } + } catch (e) { + debugPrint('[DELIVERIES][DELIVERYLOG] ❌ Error getting starttime: $e'); + // Set a fallback starttime even on error + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + } + + // CRITICAL: Ensure starttime is never empty + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Final fallback: starttime was empty, using: $startTime', + ); + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG] 📝 Final starttime for orderId $orderId: $startTime', + ); + + // Create base payload with starttime + final base = { + 'logid': 0, + 'tenantid': order['tenantid'] ?? 0, + 'partnerid': order['partnerid'] ?? 0, + 'locationid': order['locationid'] ?? 0, + 'orderheaderid': order['orderheaderid'] ?? 0, + 'deliveryid': order['deliveryid'] ?? 0, + 'userid': order['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'active', + 'starttime': startTime, // Include starttime in base payload + }; + + _deliveryBasePayload[orderId] = base; + + // Save to SharedPreferences for persistence + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'deliverylog_${orderId}_tenantid', + (base['tenantid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_partnerid', + (base['partnerid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_locationid', + (base['locationid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_orderheaderid', + (base['orderheaderid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_deliveryid', + (base['deliveryid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_userid', + (base['userid'] ?? 0).toString(), + ); + await prefs.setString('deliverylog_${orderId}_orderid', orderId); + await prefs.setString('deliverylog_${orderId}_orderstatus', 'active'); + await prefs.setString( + 'deliverylog_${orderId}_starttime', + startTime, + ); // Save starttime + } catch (e) { + debugPrint('[DELIVERIES][DELIVERYLOG] Error saving payload: $e'); + } + + // Post once immediately (don't await - let it run in background) + _postDeliveryLog(orderId, order); + debugPrint( + '[DELIVERIES][DELIVERYLOG] 📤 Posted initial log for orderId: $orderId', + ); + + // Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds + final timer = Timer.periodic(const Duration(seconds: 30), (t) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)', + ); + _postDeliveryLog(orderId, order); + }); + + _deliveryTimers[orderId] = timer; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)', + ); + } + + // Reset cumulative distance when order becomes active + Future _resetCumulativeDistance(String deliveryId) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + debugPrint( + '[DELIVERIES] 🧹 Reset cumulative distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[DELIVERIES] Error resetting cumulative distance: $e'); + } + } + + void _postDeliveryLog(String orderId, Map order) { + if (!mounted) { + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Widget disposed, skipping'); + return; + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}', + ); + + // Use Future.microtask to ensure the async operation runs independently + Future.microtask(() => _performPost(orderId)); + } + + Future _performPost(String orderId) async { + try { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] 🔄 Starting _performPost for orderId: $orderId', + ); + + Map? base = _deliveryBasePayload[orderId]; + + if (base == null) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Base is null, loading from SharedPreferences', + ); + try { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Widget unmounted after prefs load', + ); + return; + } + + base = { + 'logid': 0, + 'tenantid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_tenantid') ?? '0', + ) ?? + 0, + 'partnerid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_partnerid') ?? '0', + ) ?? + 0, + 'locationid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_locationid') ?? '0', + ) ?? + 0, + 'orderheaderid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_orderheaderid') ?? + '0', + ) ?? + 0, + 'deliveryid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0', + ) ?? + 0, + 'userid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_userid') ?? '0', + ) ?? + 0, + 'orderid': + prefs.getString('deliverylog_${orderId}_orderid') ?? orderId, + 'orderstatus': + prefs.getString('deliverylog_${orderId}_orderstatus') ?? + 'active', + 'starttime': + prefs.getString('deliverylog_${orderId}_starttime') ?? + '', // Load starttime + }; + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Base loaded from prefs: $base', + ); + } catch (e) { + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Error loading base: $e'); + return; + } + } + + // At this point, base is guaranteed to be non-null (either from cache or created above) + final basePayload = base; // Flow analysis ensures base is non-null here + + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Getting coordinates...'); + + // CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates + final coords = await _getValidCoordinates().timeout( + const Duration(seconds: 10), // Increased timeout to allow retries + onTimeout: () { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ Coordinate timeout after retries', + ); + return null; + }, + ); + + if (!mounted) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Widget unmounted after coords', + ); + return; + } + + // CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates + if (coords == null || + coords.$1.isEmpty || + coords.$2.isEmpty || + coords.$1 == '0' || + coords.$2 == '0') { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})', + ); + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)', + ); + return; // Skip this post - don't send invalid coordinates + } + + // Validate coordinate ranges + final latDouble = double.tryParse(coords.$1) ?? 0.0; + final lngDouble = double.tryParse(coords.$2) ?? 0.0; + if (latDouble == 0 || + lngDouble == 0 || + latDouble.abs() > 90 || + lngDouble.abs() > 180) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)', + ); + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)', + ); + return; // Skip this post - don't send invalid coordinates + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}', + ); + + // Cumulative KM is tracked exclusively by LiveTrackingService (high-frequency, every 3s). + // Do not accumulate here to avoid race conditions with concurrent writers. + + final now = DateTime.now(); + final logdate = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + + // CRITICAL: Ensure starttime is always included in payload + final starttimeValue = basePayload['starttime']?.toString() ?? ''; + if (starttimeValue.isEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback', + ); + } + + // CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point + final payload = { + ...basePayload, + 'logdate': logdate, + 'latitude': coords.$1, // Guaranteed non-null and valid + 'longitude': coords.$2, // Guaranteed non-null and valid + 'starttime': starttimeValue.isNotEmpty + ? starttimeValue + : '', // CRITICAL: Always include starttime + }; + + // Validate payload has all required fields + final requiredFields = [ + 'tenantid', + 'partnerid', + 'locationid', + 'orderheaderid', + 'deliveryid', + 'userid', + 'orderid', + 'orderstatus', + 'starttime', + ]; + final missingFields = requiredFields + .where((field) => payload[field] == null || payload[field] == '') + .toList(); + if (missingFields.isNotEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields', + ); + } + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + debugPrint('[DELIVERIES][DELIVERYLOG][POST] 📤 Sending to API: $url'); + debugPrint('[DELIVERIES][DELIVERYLOG][POST] 📦 Payload: $payload'); + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ✅ starttime in payload: "${payload['starttime']}"', + ); + + await _deliveryLogProvider + .createDeliveryLog(url, payload) + .timeout( + const Duration(seconds: 8), + onTimeout: () { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ API timeout for orderId: $orderId', + ); + throw TimeoutException('API timeout', const Duration(seconds: 8)); + }, + ); + + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}', + ); + } catch (e, stackTrace) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ ERROR for orderId: $orderId - $e', + ); + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Stack trace: $stackTrace'); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + + if (_picked.isNotEmpty) { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Pending deliveries'), + content: const Text( + 'Are you sure you want to close? There are deliveries pending.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Yes'), + ), + ], + ), + ); + if (confirm == true && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + child: SafeArea( + child: Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + centerTitle: false, + toolbarHeight: 70, // Same as your summary page + titleSpacing: 0, + title: Padding( + padding: const EdgeInsets.only(left: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Transform.translate( + offset: Offset(0, 6), + child: Text( + "DELIVERIES", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ), + + /// Map view row on right side + Transform.translate( + offset: Offset(-20, 2), + child: MapViewRow( + deliveries: _picked, + preservedStepNumbers: _preservedStepNumbers, + ), + ), + ], + ), + ), + + /// Divider under AppBar (same as summary page) + bottom: PreferredSize( + preferredSize: Size.fromHeight(1), + child: Divider(color: Colors.grey, height: 1), + ), + ), + body: Stack( + children: [ + _picked.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 80), + Transform.translate( + offset: Offset(0, -1.5), + child: Image.asset( + "assets/images/Doormile Bike.png", + errorBuilder: (c, e, s) => const Icon( + Icons.delivery_dining, + size: 120, + color: Colors.grey, + ), + ), + ), + const SizedBox(height: 16), + Transform.translate( + offset: Offset(0, -1.5), + child: Text( + "No Deliveries at the moment", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade500, + ), + ), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _fetchPicked, + child: ListView.builder( + padding: EdgeInsets.only( + left: 2, + right: 2, + top: 8, + bottom: _activeDeliveries.isNotEmpty + ? 80 + : 8, // Add bottom padding for banner + ), + itemCount: _picked.length, + itemBuilder: (context, index) { + final item = _picked[index]; + final int displayStep = _getDisplayStepNumber( + _picked, + index, + ); + final String distanceStr = _distanceKmDisplay(item); + final String orderId = (item['orderid'] ?? '') + .toString(); + final String status = + (item['orderstatus']?.toString().toLowerCase() ?? + '') + .trim(); + + // Logic for enabling cards: + // 1. Skipped orders are ALWAYS enabled (so they can be un-skipped or completed) + // 2. The first non-skipped, non-cancelled, non-delivered order is enabled + // 3. All other orders are disabled + + final bool isSkipped = status == 'skipped'; + final bool isCancelledOrDelivered = + status == 'cancelled' || status == 'delivered'; + final bool hasActive = _hasActiveDelivery(); + + // Don't show cancelled or delivered orders + if (isCancelledOrDelivered) { + return const SizedBox.shrink(); + } + + // Skipped orders: only enable when there is NO other active delivery + if (isSkipped && !isCancelledOrDelivered) { + return DeliveryCard( + key: ValueKey('delivery_$orderId'), + item: item, + displayStep: displayStep, + distanceStr: distanceStr, + enabled: !hasActive, + isSkipped: true, + ); + } + + // Find the first non-skipped order to enable + int firstEnabledIndex = -1; + for (int i = 0; i < _picked.length; i++) { + final orderStatus = + (_picked[i]['orderstatus'] + ?.toString() + .toLowerCase() ?? + '') + .trim(); + if (orderStatus != 'skipped' && + orderStatus != 'cancelled' && + orderStatus != 'delivered') { + firstEnabledIndex = i; + break; + } + } + + // Enable if this is the first available non-skipped order + // AND there is no active delivery running. + final bool enabled = + !hasActive && + firstEnabledIndex >= 0 && + index == firstEnabledIndex; + + return DeliveryCard( + key: ValueKey('delivery_$orderId'), + item: item, + displayStep: displayStep, + distanceStr: distanceStr, + enabled: enabled, + isSkipped: false, + ); + }, + ), + ), + // Bottom banner for active deliveries (Swiggy/Zomato style) + if (_activeDeliveries.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _ActiveDeliveryBanner( + activeDeliveries: _activeDeliveries, + onTap: (delivery) async { + await startDelivery(delivery); + // Refresh after returning from map screen + if (mounted) { + await Future.delayed(const Duration(milliseconds: 500)); + _fetchPicked(); + } + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ------------------------------------------------------------------------- +// ACTIVE DELIVERY BANNER (Swiggy/Zomato style) +// ------------------------------------------------------------------------- +class _ActiveDeliveryBanner extends StatelessWidget { + final List> activeDeliveries; + final Function(Map) onTap; + + const _ActiveDeliveryBanner({ + required this.activeDeliveries, + required this.onTap, + }); + + String _getDeliveryAddress(Map delivery) { + final address = + delivery['deliveryaddress'] ?? + delivery['DeliveryAddress'] ?? + delivery['address'] ?? + ''; + if (address.toString().length > 40) { + return '${address.toString().substring(0, 40)}...'; + } + return address.toString(); + } + + @override + Widget build(BuildContext context) { + if (activeDeliveries.isEmpty) { + return const SizedBox.shrink(); + } + + // Show first active delivery (or show count if multiple) + final delivery = activeDeliveries.first; + final count = activeDeliveries.length; + + return Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onTap(delivery), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Active indicator icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.two_wheeler, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + // Delivery info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + count > 1 + ? '$count Active Deliveries' + : 'Active Delivery', + style: TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + if (count > 1) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: TextStyle( + color: Colors.white, + fontSize: FontConstants.small(context).sp, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + _getDeliveryAddress(delivery), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 17, + fontFamily: FontConstants.fontFamily, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Arrow icon + const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/done.dart b/lib/xpress/views/Dashboard/deliveries/done.dart new file mode 100644 index 0000000..4b231e3 --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/done.dart @@ -0,0 +1,284 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// DELIVERIES DONE SCREEN +// ------------------------------------------------------------------------- +class DeliveriesDone extends StatefulWidget { + final bool isCancelled; + final int bonusPoints; + + const DeliveriesDone({ + super.key, + this.isCancelled = false, + this.bonusPoints = 0, + }); + + @override + State createState() => _DeliveriesDoneState(); +} + +class _DeliveriesDoneState extends State { + late ConfettiController _confettiController; + final GlobalKey _scratcherKey = GlobalKey(); + double _opacity = 0.0; + bool _isScratched = false; // Track scratch state + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController( + duration: const Duration(seconds: 3), + ); + } + + @override + void dispose() { + _confettiController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Check if we should show the scratch card + final bool showScratchCard = !widget.isCancelled && widget.bonusPoints > 0; + + return Scaffold( + backgroundColor: Colors.white, + body: Stack( + children: [ + // Main Content + SafeArea( + child: showScratchCard + ? _buildScratchCardContent() + : _buildStandardContent(), + ), + + // Confetti Layer (on top) + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + shouldLoop: false, + colors: const [ + Colors.green, + Colors.blue, + Colors.pink, + Colors.orange, + Colors.purple, + ], + createParticlePath: drawStar, + ), + ), + ], + ), + ); + } + + Widget _buildStandardContent() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + Lottie.asset( + widget.isCancelled + ? 'assets/lotties/Error Occurred!.json' + : 'assets/lotties/result page succes.json', + height: 220, + repeat: false, + errorBuilder: (c, e, s) => Icon( + widget.isCancelled ? Icons.cancel : Icons.check_circle, + size: 120, + color: widget.isCancelled ? Colors.red : Colors.green, + ), + ), + const SizedBox(height: 20), + Text( + widget.isCancelled ? 'Delivery Cancelled' : 'Delivery Completed!', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + Text( + widget.isCancelled + ? 'This order was cancelled.' + : 'Great job! Your delivery was successful.', + style: const TextStyle(fontSize: 16, color: Colors.grey), + textAlign: TextAlign.center, + ), + const Spacer(), + _buildDoneButton(isEnabled: true), + ], + ); + } + + Widget _buildScratchCardContent() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + Text( + 'You won a Scratch Card!', + style: TextStyle( + fontSize: 28, // Increased + fontWeight: FontWeight.bold, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 12), + Text( + 'Scratch to reveal your bonus points', + style: TextStyle( + fontSize: 18, // Increased + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 30), + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Scratcher( + key: _scratcherKey, + brushSize: 50, + threshold: 50, + color: ColorConstants.primaryColor, + onChange: (value) { + // Optional: haptic feedback or sound while scratching + }, + onThreshold: () { + _confettiController.play(); + setState(() { + _opacity = 1.0; + _isScratched = true; // Enable button + }); + }, + // Custom cover content instead of just solid color + // Was `doormilelauncher.png`, which is not in this repo — the + // same file ships here under the name below, and the scratch + // card was rendering an exception instead of its cover. + image: Image.asset( + 'assets/images/doormile_icon.png', + fit: BoxFit.scaleDown, + width: 100, // Constrain width so it fits nicely + height: 100, + ), + child: Container( + width: 250, + height: 250, + color: Colors.white, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.monetization_on, + size: 80, + color: Colors.amber, + ), + const SizedBox(height: 16), + Text( + '${widget.bonusPoints}', + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + 'Points', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const Spacer(), + _buildDoneButton(isEnabled: _isScratched), + ], + ); + } + + Widget _buildDoneButton({required bool isEnabled}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isEnabled + ? ColorConstants.primaryColor + : Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: isEnabled + ? () { + Get.offAll(() => const BottomPage(initialIndex: 1)); + } + : null, + child: const Text( + 'Done', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + + Path drawStar(Size size) { + // Method to draw star shape for confetti + double degToRad(double deg) => deg * (math.pi / 180.0); + + const numberOfPoints = 5; + final halfWidth = size.width / 2; + final externalRadius = halfWidth; + final internalRadius = halfWidth / 2.5; + final degreesPerStep = degToRad(360 / numberOfPoints); + final halfDegreesPerStep = degreesPerStep / 2; + final path = Path(); + final fullAngle = degToRad(360); + path.moveTo(size.width, halfWidth); + + for (double step = 0; step < fullAngle; step += degreesPerStep) { + path.lineTo( + halfWidth + externalRadius * math.cos(step), + halfWidth + externalRadius * math.sin(step), + ); + path.lineTo( + halfWidth + internalRadius * math.cos(step + halfDegreesPerStep), + halfWidth + internalRadius * math.sin(step + halfDegreesPerStep), + ); + } + path.close(); + return path; + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/map.dart b/lib/xpress/views/Dashboard/deliveries/map.dart new file mode 100644 index 0000000..686a1e2 --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/map.dart @@ -0,0 +1,891 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// SCREEN 1: DELIVERY MAP PREVIEW (Shows route, has "Start" button) +// ------------------------------------------------------------------------- +class _DeliveryMapScreen extends StatefulWidget { + final Map delivery; + final _MyDeliveriesState? parentState; + const _DeliveryMapScreen({required this.delivery, this.parentState}); + + @override + State<_DeliveryMapScreen> createState() => _DeliveryMapScreenState(); +} + +class _DeliveryMapScreenState extends State<_DeliveryMapScreen> { + DoormileMapController? _mapController; + late final LatLng _pickupLocation; + late final LatLng _dropLocation; + List _markers = const []; + List _polylines = const []; + bool _isNavigating = false; // Prevent multiple clicks + + @override + void initState() { + super.initState(); + _resolveLocationsFromDelivery(); + _setMarkers(); + _createRealRoute(); + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + void _resolveLocationsFromDelivery() { + final d = widget.delivery; + final double pickLat = _parseD(d['pickuplat'] ?? d['PickupLat']); + final double pickLon = _parseD(d['pickuplon'] ?? d['PickupLon']); + final double dropLat = _parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'], + ); + final double dropLon = _parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'], + ); + final double riderLat = _parseD(d['riderslat']); + final double riderLon = _parseD(d['riderslon']); + + final bool hasPickup = pickLat != 0 && pickLon != 0; + final bool hasDrop = dropLat != 0 && dropLon != 0; + + final LatLng pickup = hasPickup + ? LatLng(pickLat, pickLon) + : (riderLat != 0 && riderLon != 0 + ? LatLng(riderLat, riderLon) + : const LatLng(10.998356, 76.977596)); + + final LatLng drop = hasDrop + ? LatLng(dropLat, dropLon) + : const LatLng(11.004556, 76.967696); + + _pickupLocation = pickup; + _dropLocation = drop; + } + + Future _setMarkers() async { + final pickupIcon = await MapMarkerIcons.pin(Colors.red); + final dropIcon = await MapMarkerIcons.pin(Colors.green); + if (!mounted) return; + setState(() { + _markers = [ + DoormileMapMarker( + id: 'pickup', + position: _pickupLocation, + icon: pickupIcon, + ), + DoormileMapMarker(id: 'drop', position: _dropLocation, icon: dropIcon), + ]; + }); + } + + Future _createRealRoute() async { + final route = await RoutingService.getRoute( + origin: _pickupLocation, + destination: _dropLocation, + ); + + if (!mounted) return; + + // Fall back to a straight line so the screen still shows the connection + // when routing is unavailable. + final points = (route != null && route.points.isNotEmpty) + ? route.points + : [_pickupLocation, _dropLocation]; + + if (route == null) debugPrint('[MAP_PREVIEW] No route found'); + + setState(() { + _polylines = [ + DoormileMapPolyline( + id: 'real_route', + points: points, + color: ColorConstants.primaryColor, + width: 6, + ), + ]; + }); + + _fitMapToRoute(); + } + + Future _fitMapToRoute() async { + final controller = _mapController; + if (!mounted || controller == null) return; + + await controller.fitBounds( + LatLngBounds( + southwest: LatLng( + math.min(_pickupLocation.latitude, _dropLocation.latitude), + math.min(_pickupLocation.longitude, _dropLocation.longitude), + ), + northeast: LatLng( + math.max(_pickupLocation.latitude, _dropLocation.latitude), + math.max(_pickupLocation.longitude, _dropLocation.longitude), + ), + ), + padding: 80, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + SizedBox.expand( + child: DoormileMap( + initialCameraPosition: CameraPosition( + target: _pickupLocation, + zoom: 14.5, + ), + markers: _markers, + polylines: _polylines, + onMapReady: (controller) { + _mapController = controller; + _fitMapToRoute(); + }, + ), + ), + Positioned( + top: 50, + left: 16, + child: CircleAvatar( + backgroundColor: Colors.white, + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Navigator.pop(context), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: LayoutBuilder( + builder: (ctx, constraints) { + final double maxSheetHeight = + MediaQuery.of(ctx).size.height * 0.35; + // ignore: unused_local_variable + final double allowedHeight = math.min( + constraints.maxHeight, + maxSheetHeight, + ); + return ListView( + padding: EdgeInsets.zero, + shrinkWrap: true, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Customer Details', + style: TextStyle( + fontSize: FontConstants.xxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + InkWell( + onTap: () async { + // Just launch dialer; PiP is handled only from navigation screen + final phone = + (widget.delivery['deliverycontactno'] ?? '') + .toString(); + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => const Icon( + Icons.phone, + size: 27, + color: Colors.green, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(thickness: 1), + const SizedBox(height: 8), + _buildCustomerInfo(), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: _isNavigating + ? null + : () async { + // Prevent multiple clicks + if (_isNavigating || + !mounted || + !context.mounted) + return; + + setState(() { + _isNavigating = true; + }); + + try { + // Capture screen size before navigation + final screenSize = + MediaQuery.of(context).size * + MediaQuery.of(context).devicePixelRatio; + + final dc = Get.put( + DeliveriesController(), + permanent: true, + ); + final d = widget.delivery; + final int deliveryId = + int.tryParse( + '${d['deliveryid'] ?? d['DeliveryId'] ?? 0}', + ) ?? + 0; + final String orderId = + (d['orderid'] ?? d['OrderId'] ?? '') + .toString(); + final int orderHeaderId = + int.tryParse( + '${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}', + ) ?? + 0; + // Save ridertime start at the moment navigation is started + try { + if (deliveryId > 0) { + final prefs = + await SharedPreferences.getInstance(); + // 1) Save rider time start (existing behaviour) + await prefs.setString( + 'ridertime_start_$deliveryId', + DateTime.now().toIso8601String(), + ); + // 2) Save ETA end time for this order so PiP timer can resume correctly + final rawEta = d['eta']; + int etaMinutes = 0; + if (rawEta != null) { + etaMinutes = + int.tryParse(rawEta.toString()) ?? + 0; + } + if (etaMinutes > 0) { + final now = DateTime.now(); + final endTime = + now + .add( + Duration( + minutes: etaMinutes, + ), + ) + .millisecondsSinceEpoch ~/ + 1000; // store seconds + await prefs.setInt( + 'eta_endtime_$orderId', + endTime, + ); + debugPrint( + '[ACTIVE][ETA] Saved eta_endtime_$orderId -> $endTime (eta=$etaMinutes min)', + ); + } + debugPrint( + '[ACTIVE] Saved ridertime_start for deliveryId=$deliveryId', + ); + } + } catch (e) { + debugPrint( + '[ACTIVE] Error saving ridertime_start: $e', + ); + } + + final parentState = + widget.parentState ?? + context + .findAncestorStateOfType< + _MyDeliveriesState + >(); + + // ✅ CRITICAL: Check active delivery BEFORE navigation + if (parentState != null) { + final currentOrderId = + (d['orderid'] ?? d['OrderId'] ?? '') + .toString(); + final activeOrderIds = parentState + ._activeDeliveries + .map( + (del) => + (del['orderid'] ?? + del['OrderId'] ?? + '') + .toString(), + ) + .where((id) => id.isNotEmpty) + .toSet(); + + // Block if there's a different active delivery + if (parentState + ._activeDeliveries + .isNotEmpty && + !activeOrderIds.contains( + currentOrderId, + )) { + if (mounted) { + setState(() { + _isNavigating = false; + }); + showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext dialogContext) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all( + 24, + ), + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 20, + ), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: + Alignment.bottomRight, + colors: [ + Colors.orange.shade400, + Colors.red.shade500, + ], + ), + ), + child: Column( + mainAxisSize: + MainAxisSize.min, + children: [ + // Icon + Container( + padding: + const EdgeInsets.all( + 16, + ), + decoration: + BoxDecoration( + color: Colors + .white + .withOpacity( + 0.2, + ), + shape: BoxShape + .circle, + ), + child: const Icon( + Icons.warning_rounded, + color: Colors.white, + size: 48, + ), + ), + const SizedBox( + height: 20, + ), + // Title + Text( + 'Active Delivery in Progress', + textAlign: + TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: + FontConstants.xxLarge( + context, + ), + fontWeight: + FontWeight.bold, + fontFamily: + FontConstants + .fontFamily, + ), + ), + const SizedBox( + height: 12, + ), + // Message + Text( + 'Please complete your active delivery first before starting another delivery.', + textAlign: + TextAlign.center, + style: TextStyle( + color: Colors.white + .withOpacity( + 0.95, + ), + fontSize: + FontConstants.medium( + context, + ), + height: 1.4, + fontFamily: + FontConstants + .fontFamily, + ), + ), + const SizedBox( + height: 24, + ), + // Action Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.of( + dialogContext, + ).pop(); + // Navigate to active delivery + if (parentState + ._activeDeliveries + .isNotEmpty) { + parentState + .startDelivery( + parentState + ._activeDeliveries + .first, + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + Colors.white, + foregroundColor: + Colors + .red + .shade600, + padding: + const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular( + 12, + ), + ), + elevation: 2, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment + .center, + children: [ + const Icon( + Icons + .two_wheeler, + size: 22, + ), + const SizedBox( + width: 8, + ), + Text( + 'View Active Delivery', + style: TextStyle( + fontSize: + FontConstants.regular( + context, + ), + fontWeight: + FontWeight + .bold, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ], + ), + ), + ), + const SizedBox( + height: 12, + ), + // Close Button + TextButton( + onPressed: () { + Navigator.of( + dialogContext, + ).pop(); + }, + child: Text( + 'Close', + style: TextStyle( + color: Colors.white + .withOpacity( + 0.9, + ), + fontSize: 15, + fontWeight: + FontWeight.w500, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + return; // Return early to prevent starting another delivery + } + } + + if (deliveryId > 0 && orderId.isNotEmpty) { + String riderLatStr = '0'; + String riderLngStr = '0'; + try { + final position = + await Geolocator.getCurrentPosition( + locationSettings: + const LocationSettings( + accuracy: + LocationAccuracy.medium, + timeLimit: Duration( + seconds: 3, + ), + ), + ).timeout( + const Duration(seconds: 3), + ); + riderLatStr = position.latitude + .toStringAsFixed(6); + riderLngStr = position.longitude + .toStringAsFixed(6); + } catch (e) { + debugPrint( + '[ACTIVE] Error getting rider location: $e', + ); + try { + final lastPos = + await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + riderLatStr = lastPos.latitude + .toStringAsFixed(6); + riderLngStr = lastPos.longitude + .toStringAsFixed(6); + } + } catch (_) {} + } + + // ✅ CRITICAL: Navigate FIRST, then handle status updates + if (!mounted || !context.mounted) { + setState(() { + _isNavigating = false; + }); + return; + } + + // Navigate immediately - this must happen + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + _RiderNavigationScreen( + delivery: widget.delivery, + parentState: widget.parentState, + ), + ), + ); + + // Reset navigation state after navigation completes + setState(() { + _isNavigating = false; + }); + + // Continue with status updates and PiP in background + debugPrint( + '[ACTIVE] Updating status for deliveryId=$deliveryId orderId=$orderId lat=$riderLatStr lng=$riderLngStr', + ); + + final ok = await dc.updateActiveStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + orderId: orderId, + ); + + debugPrint( + '[ACTIVE] Status update result: $ok', + ); + + // ✅ CRITICAL: ENFORCE PiP when delivery becomes active (compulsory) + if (ok && !dc.isPipEnabled.value) { + try { + debugPrint( + '[ACTIVE] Delivery is now active - Enforcing PiP mode', + ); + final floating = Floating(); + const rational = Rational.landscape(); + + final height = + (screenSize.height * 0.5).toInt(); + final width = (screenSize.width * 0.9) + .toInt(); + + final arguments = ImmediatePiP( + aspectRatio: rational, + sourceRectHint: math.Rectangle( + ((screenSize.width - width) ~/ 2) + .toInt(), + ((screenSize.height - height) ~/ + 2) + .toInt(), + width, + height, + ), + ); + + await floating.enable(arguments); + dc.isPipEnabled.value = true; + debugPrint( + '[ACTIVE] PiP enabled successfully', + ); + + // Also try method channel as backup + try { + const channel = MethodChannel( + 'doormile/pip', + ); + await channel.invokeMethod( + 'enterPip', + ); + } catch (_) {} + } catch (e) { + debugPrint( + '[ACTIVE] Error enabling PiP: $e', + ); + } + } + + if (parentState != null) { + final previousActive = + parentState._activeDeliveryOrderId; + if (previousActive != null && + previousActive != orderId) { + parentState._stopDeliveryPosting( + previousActive, + ); + } + + parentState._activeDeliveryOrderId = + orderId; + d['orderstatus'] = 'active'; + + final orderKey = parentState + ._getOrderKey(d); + if (parentState._skippedOrdersCache + .containsKey(orderKey)) { + parentState._skippedOrdersCache + .remove(orderKey); + parentState._skippedOrderTimestamps + .remove(orderKey); + await parentState + ._saveSkippedOrdersCache(); + debugPrint( + '[ACTIVE] Removed from skipped cache (resumed): $orderKey', + ); + } + + await parentState._startDeliveryPosting( + d, + ); + + try { + final prefs = + await SharedPreferences.getInstance(); + await prefs.setString( + 'active_delivery_order_id', + orderId, + ); + } catch (e) { + debugPrint( + '[ACTIVE] Error saving active delivery ID: $e', + ); + } + + // ignore: invalid_use_of_protected_member + parentState.setState(() {}); + } + } else { + debugPrint( + '[ACTIVE] Invalid deliveryId: $deliveryId or orderId: $orderId', + ); + setState(() { + _isNavigating = false; + }); + } + } catch (e) { + debugPrint( + '[ACTIVE] Error in navigation flow: $e', + ); + if (mounted) { + setState(() { + _isNavigating = false; + }); + } + } + }, + child: _isNavigating + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(width: 12), + Text( + 'Starting...', + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + ], + ) + : Text( + 'Start Navigation', + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCustomerInfo() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + 'Name:', + (widget.delivery['deliverycustomer'] ?? 'Customer').toString(), + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Address:', + (widget.delivery['deliveryaddress'] ?? 'Address not available') + .toString(), + isExpanded: true, + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Order ID:', + '#${(widget.delivery['orderid'] ?? '').toString()}', + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Distance:', + '${(widget.delivery['kms'] ?? '0').toString()} km', + valueColor: Colors.red, + ), + ], + ); + } + + Widget _buildInfoRow( + String label, + String value, { + bool isExpanded = false, + Color? valueColor, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(width: 15), + isExpanded + ? Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: FontConstants.large(context), + fontFamily: FontConstants.fontFamily, + color: valueColor ?? Colors.black87, + ), + ), + ) + : Text( + value, + style: TextStyle( + fontSize: FontConstants.large(context), + fontFamily: FontConstants.fontFamily, + color: valueColor ?? Colors.black87, + ), + ), + ], + ); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/map_btn.dart b/lib/xpress/views/Dashboard/deliveries/map_btn.dart new file mode 100644 index 0000000..0501c66 --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/map_btn.dart @@ -0,0 +1,40 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// MAP VIEW BUTTON (Customer locations) +// ------------------------------------------------------------------------- +class MapViewRow extends StatelessWidget { + final List> deliveries; + final Map? preservedStepNumbers; + + const MapViewRow({ + super.key, + required this.deliveries, + this.preservedStepNumbers, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MultiCustomerMapScreen( + deliveries: deliveries, + preservedStepNumbers: preservedStepNumbers ?? {}, + ), + ), + ); + }, + child: Image.asset( + 'assets/images/customermap.png', + color: ColorConstants.primaryColor, + height: 32, + width: 32, + errorBuilder: (c, e, s) => + const Icon(Icons.map, size: 32, color: Colors.blue), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/multi_map.dart b/lib/xpress/views/Dashboard/deliveries/multi_map.dart new file mode 100644 index 0000000..0e44aea --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/multi_map.dart @@ -0,0 +1,743 @@ +part of 'deliveries.dart'; + +class _StepPoint { + final int step; + final LatLng position; + _StepPoint(this.step, this.position); +} + +// ------------------------------------------------------------------------- +// MULTI CUSTOMER MAP (Shows all deliveries with real step numbers) +// ------------------------------------------------------------------------- +class MultiCustomerMapScreen extends StatefulWidget { + final List> deliveries; + final Map preservedStepNumbers; + + const MultiCustomerMapScreen({ + super.key, + required this.deliveries, + this.preservedStepNumbers = const {}, + }); + + @override + State createState() => _MultiCustomerMapScreenState(); +} + +class _MultiCustomerMapScreenState extends State { + DoormileMapController? mapController; + List markers = const []; + List polylines = const []; + LatLng? currentLocation; + bool _isLoading = true; + final Map> _deliveryMap = {}; + + @override + void initState() { + super.initState(); + if (widget.deliveries.isNotEmpty) { + final firstDelivery = widget.deliveries.first; + final dropLat = _parseD( + firstDelivery['droplat'] ?? firstDelivery['deliverylat'] ?? 0, + ); + final dropLon = _parseD( + firstDelivery['droplon'] ?? firstDelivery['deliverylong'] ?? 0, + ); + if (dropLat != 0 && dropLon != 0) { + currentLocation = LatLng(dropLat, dropLon); + } else { + currentLocation = const LatLng(11.018356, 77.012596); + } + } else { + currentLocation = const LatLng(11.018356, 77.012596); + } + _loadMapData(); + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + int _getStepNumber(Map order) { + final dynamic raw = order['step'] ?? order['Step']; + final int step = raw == null + ? 0 + : (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0); + return step; + } + + String _getOrderKey(Map order) { + final deliveryId = (order['deliveryid'] ?? '').toString(); + final orderId = (order['orderid'] ?? '').toString(); + return deliveryId.isNotEmpty ? 'delivery_$deliveryId' : 'order_$orderId'; + } + + int _getPreservedOrCurrentStep(Map order) { + final orderKey = _getOrderKey(order); + if (widget.preservedStepNumbers.containsKey(orderKey)) { + return widget.preservedStepNumbers[orderKey]!; + } + final currentStep = _getStepNumber(order); + return currentStep; + } + + Future _loadMapData() async { + try { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + + _getCurrentLocation().then((_) { + if (mounted) { + _createDeliveryMarkers(); + } + }); + } catch (e) { + debugPrint('[CUSTOMER_MAP] Error loading map data: $e'); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _getCurrentLocation() async { + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + currentLocation = LatLng(lastPos.latitude, lastPos.longitude); + }); + } + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + currentLocation = LatLng(lastPos.latitude, lastPos.longitude); + }); + } + return; + } + } + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + distanceFilter: 0, + timeLimit: Duration(seconds: 5), + ), + ); + } catch (e) { + debugPrint( + '[CUSTOMER_MAP] Timeout getting location, using last known: $e', + ); + position = await Geolocator.getLastKnownPosition(); + } + + final safePosition = position; + if (safePosition != null && mounted) { + setState(() { + currentLocation = LatLng( + safePosition.latitude, + safePosition.longitude, + ); + }); + } + } catch (e) { + debugPrint('[CUSTOMER_MAP] Error getting location: $e'); + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + currentLocation = LatLng(lastPos.latitude, lastPos.longitude); + }); + } + } catch (_) {} + } + } + + int? _getNextDeliveryStep() { + // Find the first non-skipped delivery (next delivery from user location) + for (int i = 0; i < widget.deliveries.length; i++) { + final delivery = widget.deliveries[i]; + final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + if (status != 'skipped') { + final stepNumber = _getPreservedOrCurrentStep(delivery); + if (stepNumber > 0) { + return stepNumber; + } else { + // Calculate display step for orders without step + final ordersWithStepBefore = widget.deliveries + .sublist(0, i) + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + final totalOrdersWithStep = widget.deliveries + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + return totalOrdersWithStep + (i - ordersWithStepBefore) + 1; + } + } + } + return null; + } + + Future _createDeliveryMarkers() async { + final List tempMarkers = []; + List<_StepPoint> stepPoints = []; + _deliveryMap.clear(); + + final nextStep = _getNextDeliveryStep(); + + for (int i = 0; i < widget.deliveries.length; i++) { + final delivery = widget.deliveries[i]; + final stepNumber = _getPreservedOrCurrentStep(delivery); + + int displayStep; + if (stepNumber > 0) { + displayStep = stepNumber; + } else { + final ordersWithStepBefore = widget.deliveries + .sublist(0, i) + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + final totalOrdersWithStep = widget.deliveries + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1; + } + + final double lat = _parseD( + delivery['droplat'] ?? delivery['deliverylat'], + ); + final double lon = _parseD( + delivery['droplon'] ?? delivery['deliverylong'], + ); + if (lat == 0 || lon == 0) continue; + + final orderId = (delivery['orderid'] ?? '').toString(); + final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isSkipped = status == 'skipped'; + final isNext = displayStep == nextStep; + + // Store delivery data for dialog + _deliveryMap['delivery_$orderId'] = delivery; + + final icon = await MapMarkerIcons.numberedCircle( + displayStep, + primaryColor: ColorConstants.primaryColor, + isSkipped: isSkipped, + isNext: isNext, + ); + + tempMarkers.add( + DoormileMapMarker( + id: 'delivery_$orderId', + position: LatLng(lat, lon), + icon: icon, + anchor: MarkerAnchor.center, + onTap: () { + _showCustomerDetailsSheet(delivery, displayStep); + }, + ), + ); + + stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon))); + } + + if (currentLocation != null) { + tempMarkers.add( + DoormileMapMarker( + id: 'current_location', + position: currentLocation!, + icon: await MapMarkerIcons.dot(Colors.blueAccent), + anchor: MarkerAnchor.center, + ), + ); + stepPoints.insert(0, _StepPoint(0, currentLocation!)); + } + + stepPoints.sort((a, b) => a.step.compareTo(b.step)); + + final newPolylines = await _buildRoutePolylines(stepPoints); + + if (mounted) { + setState(() { + markers = tempMarkers; + polylines = newPolylines; + }); + } + + await Future.delayed(const Duration(milliseconds: 200)); + _fitBoundsToMarkersAndPolylines(); + } + + /// Builds the route through every stop in one Directions call. + /// + /// This used to issue one request per consecutive pair of stops, so opening + /// this screen with N stops cost N billable requests. OSRM takes every stop + /// as a coordinate in one request, so the whole route is a single call. + Future> _buildRoutePolylines( + List<_StepPoint> stepPoints, + ) async { + if (stepPoints.length < 2) return const []; + + final route = await RoutingService.getRoute( + origin: stepPoints.first.position, + destination: stepPoints.last.position, + waypoints: stepPoints + .sublist(1, stepPoints.length - 1) + .map((s) => s.position) + .toList(), + ); + + if (route != null && route.points.isNotEmpty) { + return [ + DoormileMapPolyline( + id: 'route', + points: route.points, + width: 6, + color: Colors.blue, + ), + ]; + } + + debugPrint('[CUSTOMER_MAP] Routing unavailable, drawing direct segments'); + return [ + DoormileMapPolyline( + id: 'route_fallback', + points: stepPoints.map((s) => s.position).toList(), + width: 4, + color: Colors.blue.shade200, + ), + ]; + } + + Future _fitBoundsToMarkersAndPolylines() async { + final controller = mapController; + if (controller == null) return; + + double minLat = double.infinity; + double maxLat = -double.infinity; + double minLng = double.infinity; + double maxLng = -double.infinity; + + bool hasPoint = false; + + void include(LatLng pos) { + minLat = math.min(minLat, pos.latitude); + maxLat = math.max(maxLat, pos.latitude); + minLng = math.min(minLng, pos.longitude); + maxLng = math.max(maxLng, pos.longitude); + hasPoint = true; + } + + for (final m in markers) { + include(m.position); + } + for (final poly in polylines) { + for (final pos in poly.points) { + include(pos); + } + } + + if (!hasPoint) return; + + await controller.fitBounds( + LatLngBounds( + southwest: LatLng(minLat, minLng), + northeast: LatLng(maxLat, maxLng), + ), + padding: 80, + ); + } + + void _showCustomerDetailsSheet( + Map delivery, + int stepNumber, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + enableDrag: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext context) { + final customerName = (delivery['deliverycustomer'] ?? 'Customer') + .toString(); + final address = (delivery['deliveryaddress'] ?? 'Address not available') + .toString(); + final phone = (delivery['deliverycontactno'] ?? '').toString(); + final orderId = (delivery['orderid'] ?? '').toString(); + final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isSkipped = status == 'skipped'; + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with title and close icon + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Customer Details', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + ), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey.shade200, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.cancel, + size: 34, + color: Colors.red, + ), + ), + ), + ], + ), + + const Divider(thickness: 1.5), + + // Step number and status + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: isSkipped + ? Colors.orange + : ColorConstants.primaryColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Step $stepNumber', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + if (isSkipped) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'SKIPPED', + style: TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 16), + + // Customer name + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.person, size: 20, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Customer Name', + style: TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + customerName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Delivery address + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.location_on, + size: 20, + color: Colors.grey, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Delivery Address', + style: TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + address, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Phone number + if (phone.isNotEmpty) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.phone, size: 20, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Contact Number', + style: TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + phone, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Order ID + Text( + 'Order ID: $orderId', + style: const TextStyle( + fontSize: 16, + color: Colors.black, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 20), + + // Call button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () async { + // Close bottom sheet first + Navigator.of(context).pop(); + + // Small delay to ensure bottom sheet is closed + await Future.delayed( + const Duration(milliseconds: 300), + ); + + // Then make the call + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + icon: const Icon( + Icons.phone, + color: Colors.white, + size: 20, + ), + label: const Text( + 'Call', + style: TextStyle( + color: Colors.white, + fontSize: 21, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + LatLng initialTarget; + double initialZoom = 13; + + if (currentLocation != null) { + initialTarget = currentLocation!; + } else if (widget.deliveries.isNotEmpty) { + final firstDelivery = widget.deliveries.first; + final lat = _parseD( + firstDelivery['droplat'] ?? firstDelivery['deliverylat'], + ); + final lon = _parseD( + firstDelivery['droplon'] ?? firstDelivery['deliverylong'], + ); + initialTarget = (lat != 0 && lon != 0) + ? LatLng(lat, lon) + : const LatLng(11.0168, 76.9558); + } else { + initialTarget = const LatLng(11.0168, 76.9558); + } + + final initialCameraPosition = CameraPosition( + target: initialTarget, + zoom: initialZoom, + ); + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(70), + child: SafeArea( + bottom: false, + child: AppBar( + automaticallyImplyLeading: false, + backgroundColor: ColorConstants.primaryColor, + elevation: 0, + toolbarHeight: 80, + leadingWidth: double.infinity, + leading: Row( + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: 26, + ), + onPressed: () => Navigator.pop(context), + ), + const Text( + 'Delivery Route', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ], + ), + centerTitle: false, + ), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : DoormileMap( + initialCameraPosition: initialCameraPosition, + markers: markers, + polylines: polylines, + myLocationEnabled: true, + onMapReady: (controller) { + mapController = controller; + _fitBoundsToMarkersAndPolylines(); + }, + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/nav.dart b/lib/xpress/views/Dashboard/deliveries/nav.dart new file mode 100644 index 0000000..3a3874d --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/nav.dart @@ -0,0 +1,1039 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// SCREEN 2: RIDER NAVIGATION (Live tracking + auto-opens Google Maps once) +// ------------------------------------------------------------------------- +class _RiderNavigationScreen extends StatefulWidget { + final Map delivery; + final _MyDeliveriesState? parentState; + const _RiderNavigationScreen({ + // ignore: unused_element_parameter + super.key, + required this.delivery, + this.parentState, + }); + + @override + State<_RiderNavigationScreen> createState() => _RiderNavigationScreenState(); +} + +class _RiderNavigationScreenState extends State<_RiderNavigationScreen> + with WidgetsBindingObserver { + DoormileMapController? mapController; + bool _mapReady = false; + bool _isDisposed = false; + Position? _currentPosition; + StreamSubscription? _positionStream; + List _markers = const []; + MarkerIcon? _riderIcon; + MarkerIcon? _dropIcon; + LatLng? _lastRiderPosition; + late final LatLng dropLocation; + bool _isInitializing = true; + bool _hasOpenedGoogleMaps = false; + Timer? _autoOpenTimer; + Timer? _pipLoaderTimer; + bool _showPipExpandLoader = false; + bool _wasInPipBeforePause = false; + // ETA timer for full navigation screen (top-right corner) + final CountDownController _navEtaController = CountDownController(); + int _navEtaRemainingSeconds = 0; + final DeliveriesController deliveriesController = Get.put( + DeliveriesController(), + permanent: true, + ); + final Floating floating = Floating(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _resolveDropFromDelivery(); + _loadMarkerIcons(); + _initializeMap(); + _initEtaForNav(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _attemptAutoOpenNavigation(); + }); + _autoOpenTimer = Timer( + const Duration(seconds: 3), + () => _attemptAutoOpenNavigation(fromTimer: true), + ); + } + + // Initialize remaining ETA for navigation screen using same persisted end time + Future _initEtaForNav() async { + try { + final prefs = await SharedPreferences.getInstance(); + final orderId = + widget.delivery['orderid']?.toString() ?? + widget.delivery['OrderId']?.toString() ?? + ''; + if (orderId.isEmpty) return; + + final endKey = 'eta_endtime_$orderId'; + final endSeconds = prefs.getInt(endKey); + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + int remaining = 0; + + // ✅ FIX: Checking (endSeconds > nowSeconds) caused the timer to RESET if user was late. + // We must trust existing endSeconds even if it's in the past. + if (endSeconds != null && endSeconds > 0) { + remaining = endSeconds - nowSeconds; + debugPrint( + '[NAV] Loaded saved ETA end time: $endSeconds (Remaining: $remaining)', + ); + } else { + // Only start a NEW timer if one doesn't exist + final rawEta = widget.delivery['eta']; + int etaMinutes = 0; + if (rawEta != null) { + etaMinutes = int.tryParse(rawEta.toString()) ?? 0; + } + if (etaMinutes > 0) { + remaining = etaMinutes * 60; + final newEndSeconds = nowSeconds + remaining; + await prefs.setInt(endKey, newEndSeconds); + debugPrint( + '[NAV] Saved NEW ETA end time: $newEndSeconds (Remaining: $remaining)', + ); + } + } + + if (!mounted) return; + setState(() { + _navEtaRemainingSeconds = remaining.clamp(0, 24 * 60 * 60); + }); + } catch (_) { + // Ignore errors – timer simply won't show + } + } + + @override + void dispose() { + _isDisposed = true; + WidgetsBinding.instance.removeObserver(this); + _autoOpenTimer?.cancel(); + _pipLoaderTimer?.cancel(); + _positionStream?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState lifecycleState) { + if (!mounted) return; + + if (lifecycleState == AppLifecycleState.paused || + lifecycleState == AppLifecycleState.inactive) { + final wasInPip = deliveriesController.isPipEnabled.value; + _wasInPipBeforePause = wasInPip; + if (wasInPip) { + _pipLoaderTimer?.cancel(); + if (mounted) { + setState(() { + _showPipExpandLoader = true; + }); + } + } + } else if (lifecycleState == AppLifecycleState.resumed) { + // When app comes back to foreground, consider PiP session ended. + deliveriesController.isPipEnabled.value = false; + + // ✅ FIX: Refresh the timer when coming back from PiP/Background + _initEtaForNav(); + + if (_wasInPipBeforePause) { + _wasInPipBeforePause = false; + _pipLoaderTimer?.cancel(); + _pipLoaderTimer = Timer(const Duration(milliseconds: 900), () { + if (!mounted) return; + setState(() { + _showPipExpandLoader = false; + }); + }); + } + } + } + + Future enablePip( + BuildContext context, { + bool autoEnable = false, + }) async { + if (!mounted) return; + + debugPrint('[NAV] Enabling PiP for navigation screen'); + + const rational = Rational.landscape(); + final screenSize = + MediaQuery.of(context).size * MediaQuery.of(context).devicePixelRatio; + final height = (screenSize.height * 0.5).toInt(); + final width = (screenSize.width * 0.9).toInt(); + + final arguments = autoEnable + ? OnLeavePiP( + aspectRatio: rational, + sourceRectHint: math.Rectangle( + 0, + (screenSize.height ~/ 1) - (height ~/ 1), + screenSize.width.toInt(), + height, + ), + ) + : ImmediatePiP( + aspectRatio: rational, + sourceRectHint: math.Rectangle( + ((screenSize.width - width) ~/ 2).toInt(), + ((screenSize.height - height) ~/ 2).toInt(), + width, + height, + ), + ); + + final status = await floating.enable(arguments); + deliveriesController.isPipEnabled.value = true; + debugPrint('PiP enabled? $status'); + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + void _resolveDropFromDelivery() { + final d = widget.delivery; + final double dropLat = _parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'], + ); + final double dropLon = _parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'], + ); + + dropLocation = (dropLat != 0 && dropLon != 0) + ? LatLng(dropLat, dropLon) + : const LatLng(11.018356, 77.012596); + } + + Future _initializeMap() async { + try { + if (mounted) { + setState(() { + Geolocator.getLastKnownPosition().then((lastPos) { + if (lastPos != null && mounted) { + setState(() { + _currentPosition = lastPos; + _addMarkers(LatLng(lastPos.latitude, lastPos.longitude)); + }); + _startLiveTracking(); + } + }); + + if (_currentPosition == null) { + _currentPosition = Position( + latitude: dropLocation.latitude, + longitude: dropLocation.longitude, + timestamp: DateTime.now(), + accuracy: 0, + altitude: 0, + heading: 0, + speed: 0, + speedAccuracy: 0, + altitudeAccuracy: 0, + headingAccuracy: 0, + ); + + _addMarkers(dropLocation); + } + + _isInitializing = false; + }); + } + + _getCurrentLocation().then((_) { + _startLiveTracking(); + }); + } catch (e) { + debugPrint('[NAVIGATION] Error initializing map: $e'); + if (mounted) { + setState(() { + if (_currentPosition == null) { + _currentPosition = Position( + latitude: dropLocation.latitude, + longitude: dropLocation.longitude, + timestamp: DateTime.now(), + accuracy: 0, + altitude: 0, + heading: 0, + speed: 0, + speedAccuracy: 0, + altitudeAccuracy: 0, + headingAccuracy: 0, + ); + + _addMarkers(dropLocation); + } + + _isInitializing = false; + }); + } + } + } + + Future _getCurrentLocation() async { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + _currentPosition = lastPos; + _isInitializing = false; + }); + } + Geolocator.openLocationSettings(); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + _currentPosition = lastPos; + _isInitializing = false; + }); + _addMarkers(LatLng(lastPos.latitude, lastPos.longitude)); + } else if (mounted) { + setState(() => _isInitializing = false); + } + return; + } + + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 5), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[NAVIGATION] Timeout getting position, using last known: $e'); + pos = await Geolocator.getLastKnownPosition(); + } + + if (pos != null && mounted) { + setState(() { + _currentPosition = pos; + _isInitializing = false; + }); + _addMarkers(LatLng(pos.latitude, pos.longitude)); + } else if (mounted) { + setState(() => _isInitializing = false); + } + } + + void _startLiveTracking() { + _positionStream = + Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: 5, + ), + ).listen((Position position) { + if (mounted) { + setState(() { + _currentPosition = position; + _addMarkers(LatLng(position.latitude, position.longitude)); + }); + + final distance = Geolocator.distanceBetween( + position.latitude, + position.longitude, + dropLocation.latitude, + dropLocation.longitude, + ); + deliveriesController.deliverableDistance.value = distance; + + _safeAnimateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: LatLng(position.latitude, position.longitude), + zoom: 16.5, + tilt: 45, + ), + ), + ); + } + }); + } + + /// Marker bitmaps are drawn on a canvas, so they arrive asynchronously. + /// Once they land, rebuild whatever markers are already pending. + Future _loadMarkerIcons() async { + final rider = await MapMarkerIcons.dot(Colors.blueAccent); + final drop = await MapMarkerIcons.pin(Colors.red); + if (!mounted || _isDisposed) return; + setState(() { + _riderIcon = rider; + _dropIcon = drop; + _addMarkers(_lastRiderPosition ?? dropLocation); + }); + } + + /// Mutates [_markers] only — callers own the [setState] so this can be + /// invoked from inside an existing one without nesting. + void _addMarkers(LatLng riderPos) { + _lastRiderPosition = riderPos; + final riderIcon = _riderIcon; + final dropIcon = _dropIcon; + if (riderIcon == null || dropIcon == null) return; + + _markers = [ + DoormileMapMarker( + id: 'rider', + position: riderPos, + icon: riderIcon, + anchor: MarkerAnchor.center, + ), + DoormileMapMarker(id: 'drop', position: dropLocation, icon: dropIcon), + ]; + } + + Future _attemptAutoOpenNavigation({bool fromTimer = false}) async { + if (!mounted || _hasOpenedGoogleMaps) return; + final bool opened = await _openGoogleMapsNavigation(); + if (!opened && fromTimer) { + debugPrint('[NAVIGATION] Auto open failed, waiting for manual trigger'); + } + } + + Future _openGoogleMapsNavigation() async { + if (!mounted) return false; + if (_hasOpenedGoogleMaps) return true; + try { + if (Platform.isAndroid) { + try { + // ✅ CRITICAL: ENFORCE PiP when starting navigation (compulsory for active deliveries) + final dc = Get.put(DeliveriesController(), permanent: true); + + // Enable PiP using floating package (primary) + if (!dc.isPipEnabled.value) { + await enablePip(context); + // Wait a moment for PiP to activate + await Future.delayed(const Duration(milliseconds: 300)); + + // Also tell native side (if available) – best-effort + const channel = MethodChannel('doormile/pip'); + try { + await channel.invokeMethod('enterPip'); + } catch (_) {} + + dc.isPipEnabled.value = true; + } + } on PlatformException catch (e) { + debugPrint('[NAV] Method channel PiP failed: ${e.message}'); + } catch (e) { + debugPrint('[NAV] Error enabling PiP: $e'); + } + } + Position? currentPos; + try { + currentPos = await Geolocator.getLastKnownPosition(); + } catch (_) {} + + if (currentPos == null) { + try { + currentPos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 2), + ); + } catch (_) {} + } + + final originLat = currentPos?.latitude ?? dropLocation.latitude; + final originLng = currentPos?.longitude ?? dropLocation.longitude; + final destLat = dropLocation.latitude; + final destLng = dropLocation.longitude; + + final Uri nativeUri = Uri.parse( + 'google.navigation:q=$destLat,$destLng&mode=d', + ); + final Uri webUri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1' + '&origin=$originLat,$originLng' + '&destination=$destLat,$destLng' + '&travelmode=driving' + '&dir_action=navigate', + ); + + // Try native intent first without relying on canLaunch (can be flaky) + bool launched = false; + + try { + launched = await launchUrl( + nativeUri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[NAVIGATION] Native intent failed: $e'); + } + + if (!launched) { + try { + launched = await launchUrl( + webUri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[NAVIGATION] Web intent failed: $e'); + } + } + + if (launched && mounted) { + setState(() { + _hasOpenedGoogleMaps = true; + }); + debugPrint('[NAVIGATION] Opened Google Maps'); + return true; + } + + debugPrint('[NAVIGATION] Could not open Google Maps'); + } catch (e) { + debugPrint('[NAVIGATION] Error opening Google Maps: $e'); + } + return false; + } + + @override + Widget build(BuildContext context) { + return Obx(() { + if (deliveriesController.isPipEnabled.value) { + final rawEta = widget.delivery['eta']; + int etaMinutes = 0; + if (rawEta != null) { + etaMinutes = int.tryParse(rawEta.toString()) ?? 0; + } + + return PipInfoCard( + orderId: + widget.delivery['orderid']?.toString() ?? + widget.delivery['OrderId']?.toString() ?? + 'N/A', + etaMinutes: etaMinutes, + ); + } + + return Scaffold( + body: Stack( + children: [ + DoormileMap( + onMapReady: (controller) { + mapController = controller; + _mapReady = true; + }, + initialCameraPosition: CameraPosition( + target: LatLng( + _currentPosition?.latitude ?? dropLocation.latitude, + _currentPosition?.longitude ?? dropLocation.longitude, + ), + zoom: 15, + tilt: 45, + ), + markers: _markers, + myLocationEnabled: true, + compassEnabled: true, + tiltGesturesEnabled: true, + ), + if (!_hasOpenedGoogleMaps) + Positioned( + top: 50, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + decoration: BoxDecoration( + color: ColorConstants.primaryColor, + borderRadius: BorderRadius.circular(25), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(width: 12), + Text( + 'Opening Google Maps...', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + Positioned( + top: _hasOpenedGoogleMaps ? 50 : 120, + left: 16, + child: CircleAvatar( + backgroundColor: Colors.white, + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { + _autoOpenTimer?.cancel(); + // Navigate back to BottomPage on Deliveries tab + Get.offAll(() => const BottomPage(initialIndex: 1)); + }, + ), + ), + ), + if (_hasOpenedGoogleMaps) + Positioned( + top: 50, + right: 16, + child: CircleAvatar( + backgroundColor: Colors.white, + child: IconButton( + icon: const Icon(Icons.navigation, color: Colors.blue), + onPressed: () async { + await _openGoogleMapsNavigation(); + }, + tooltip: 'Open Google Maps', + ), + ), + ), + // Small real-time ETA timer on top-right in full navigation (not in PiP) + if (!deliveriesController.isPipEnabled.value && + _navEtaRemainingSeconds > 0) + Positioned( + top: 110, + right: 16, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 32, + height: 32, + child: CircularCountDownTimer( + key: ValueKey( + 'nav_timer_$_navEtaRemainingSeconds', + ), // Force rebuild if duration changes significantly + duration: _navEtaRemainingSeconds, + initialDuration: 0, + controller: _navEtaController, + width: 32, + height: 32, + ringColor: ColorConstants.primaryColor.withValues( + alpha: 0.15, + ), + fillColor: ColorConstants.primaryColor, + backgroundColor: Colors.white, + strokeWidth: 3, + strokeCap: StrokeCap.round, + textStyle: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + isReverse: true, + isReverseAnimation: true, + isTimerTextShown: true, + autoStart: true, + timeFormatterFunction: (defaultFormatter, duration) { + return duration.inSeconds <= 0 + ? 'Out' + : defaultFormatter(duration); + }, + ), + ), + const SizedBox(width: 6), + Text( + 'ETA', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ], + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Customer Details', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + InkWell( + onTap: () async { + // Enable PiP mode first before launching dialer + if (!deliveriesController.isPipEnabled.value) { + await enablePip(context); + // Wait a moment for PiP to activate + await Future.delayed( + const Duration(milliseconds: 300), + ); + } + + final phone = + (widget.delivery['deliverycontactno'] ?? '') + .toString(); + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => const Icon( + Icons.phone, + size: 27, + color: Colors.green, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + const Divider(thickness: 1.5), + const SizedBox(height: 10), + _buildCustomerInfo(), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () async { + // Optimize: Run PiP enable and Prefs saving in parallel + final d = widget.delivery; + final double? lat = double.tryParse( + "${d['droplat']}", + ); + final double? lng = double.tryParse( + "${d['droplon']}", + ); + + if (lat == null || lng == null) { + debugPrint("[RIDE] Invalid drop location"); + return; + } + + final prefsFuture = + SharedPreferences.getInstance().then(( + prefs, + ) async { + await prefs.setString( + "drop_lat", + lat.toString(), + ); + await prefs.setString( + "drop_lng", + lng.toString(), + ); + }); + + // PiP Logic + if (!deliveriesController.isPipEnabled.value) { + try { + debugPrint( + '[NAV] Enforcing PiP before Ride button', + ); + // Don't await extremely long, just enough to start transition + await enablePip(context); + await Future.delayed( + const Duration(milliseconds: 150), + ); + } catch (e) { + debugPrint('[NAV] Error enabling PiP: $e'); + } + } + + // Construct URL (Map uses current location by default if origin not specified) + final String url = + "https://www.google.com/maps/dir/?api=1&destination=$lat,$lng&travelmode=driving"; + final Uri uri = Uri.parse(url); + + // Fire and forget prefs + prefsFuture.ignore(); + + try { + // Direct launch is faster and avoids package visibility queries + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + debugPrint("[RIDE] Opening Google Maps..."); + } catch (e) { + debugPrint( + "[RIDE] Could not launch Maps: $e", + ); + } + }, + child: Text( + 'Ride', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + + SizedBox(width: 12), // space between buttons + + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + minimumSize: const Size( + 0, + 48, + ), // FIX: no infinity width + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (context) => _DeliveryBottomSheet( + delivery: widget.delivery, + parentState: widget.parentState, + ), + ); + + if (result == true) { + // ✅ If delivery updated/skipped, close navigation screen immediately + if (mounted && Navigator.canPop(context)) { + Navigator.pop(context); + } + } + }, + child: Text( + 'Update', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + if (_showPipExpandLoader) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.45), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 56, + height: 56, + child: CircularProgressIndicator( + strokeWidth: 4, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Restoring full view...', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ], + ), + ); + }); + } + + Widget _buildCustomerInfo() { + final rawCollection = widget.delivery['collectionamt']; + double collectionAmt = 0.0; + if (rawCollection != null) { + if (rawCollection is num) { + collectionAmt = rawCollection.toDouble(); + } else { + collectionAmt = double.tryParse(rawCollection.toString()) ?? 0.0; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + 'Name:', + (widget.delivery['deliverycustomer'] ?? 'Customer').toString(), + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Address:', + (widget.delivery['deliveryaddress'] ?? 'Address not available') + .toString(), + isExpanded: true, + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Order ID:', + '#${(widget.delivery['orderid'] ?? '').toString()}', + ), + if (collectionAmt > 0) ...[ + const SizedBox(height: 10), + _buildInfoRow('To Collect:', '₹${collectionAmt.toStringAsFixed(0)}'), + ], + ], + ); + } + + Widget _buildInfoRow(String label, String value, {bool isExpanded = false}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(width: 15), + isExpanded + ? Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ) + : Text( + value, + style: TextStyle( + fontSize: 18, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ], + ); + } + + void _safeAnimateCamera(CameraUpdate update) { + if (!_mapReady || _isDisposed) return; + mapController?.animateCamera(update); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/pip.dart b/lib/xpress/views/Dashboard/deliveries/pip.dart new file mode 100644 index 0000000..1af0473 --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/pip.dart @@ -0,0 +1,172 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// PIP INFO CARD (Shown when PiP mode is enabled) +// ------------------------------------------------------------------------- +class PipInfoCard extends StatefulWidget { + final String orderId; + final int etaMinutes; // ETA in minutes from API (always treat as minutes) + + const PipInfoCard({ + super.key, + required this.orderId, + required this.etaMinutes, + }); + + @override + State createState() => _PipInfoCardState(); +} + +class _PipInfoCardState extends State { + late final CountDownController _controller; + int _durationSeconds = 0; + + @override + void initState() { + super.initState(); + _controller = CountDownController(); + // Load remaining ETA from SharedPreferences so timer doesn't reset + _initDuration(); + } + + Future _initDuration() async { + try { + final prefs = await SharedPreferences.getInstance(); + final endKey = 'eta_endtime_${widget.orderId}'; + final endSeconds = prefs.getInt(endKey); + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + int remaining = 0; + if (endSeconds != null && endSeconds > nowSeconds) { + remaining = endSeconds - nowSeconds; + } else { + // Fallback: use full ETA from API + final int safeEtaMinutes = widget.etaMinutes < 0 + ? 0 + : widget.etaMinutes; + remaining = safeEtaMinutes * 60; + } + + if (!mounted) return; + setState(() { + _durationSeconds = remaining.clamp(0, 24 * 60 * 60); + }); + } catch (_) { + // In case of error, just fall back to raw ETA minutes + final int safeEtaMinutes = widget.etaMinutes < 0 ? 0 : widget.etaMinutes; + if (!mounted) return; + setState(() { + _durationSeconds = safeEtaMinutes * 60; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + // Expanded PiP: timer + key delivery details, but still relatively compact + body: Center( + child: widget.orderId.isEmpty || widget.orderId == 'N/A' + ? Text( + 'No Active Order', + style: TextStyle( + fontSize: 12, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ) + : Card( + color: Colors.white, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + width: 220, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Timer + SizedBox( + width: 72, + height: 72, + child: _durationSeconds <= 0 + ? Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants.primaryColor, + width: 3, + ), + color: Colors.white, + ), + alignment: Alignment.center, + child: Text( + 'Out', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + ), + ) + : CircularCountDownTimer( + duration: _durationSeconds, + initialDuration: 0, + controller: _controller, + width: 72, + height: 72, + ringColor: ColorConstants.primaryColor + .withValues(alpha: 0.15), + fillColor: ColorConstants.primaryColor, + backgroundColor: Colors.white, + strokeWidth: 5, + strokeCap: StrokeCap.round, + textStyle: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + isReverse: true, + isReverseAnimation: true, + isTimerTextShown: true, + autoStart: true, + timeFormatterFunction: + (defaultFormatter, duration) { + return duration.inSeconds <= 0 + ? 'Out' + : defaultFormatter(duration); + }, + ), + ), + const SizedBox(height: 4), + // Active order id + Text( + 'Active Order ID: ${widget.orderId}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/sheet.dart b/lib/xpress/views/Dashboard/deliveries/sheet.dart new file mode 100644 index 0000000..4f2435f --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/sheet.dart @@ -0,0 +1,1034 @@ +// ignore_for_file: invalid_return_type_for_catch_error + +part of 'deliveries.dart'; + +// ignore_for_file: unused_element_parameter +// Imports should be in deliveries.dart +// import 'dart:io'; +// import 'package:image_picker/image_picker.dart'; +// import 'package:shared_preferences/shared_preferences.dart'; + +// ------------------------------------------------------------------------- +// DELIVERY BOTTOM SHEET (Delivered/Cancel with payment options) +// ------------------------------------------------------------------------- +class _DeliveryBottomSheet extends StatefulWidget { + final Map delivery; + final _MyDeliveriesState? parentState; + + const _DeliveryBottomSheet({ + super.key, + required this.delivery, + this.parentState, + }); + + @override + State<_DeliveryBottomSheet> createState() => _DeliveryBottomSheetState(); +} + +class _DeliveryBottomSheetState extends State<_DeliveryBottomSheet> { + String selectedOption = ''; + bool isDeliveredSelected = true; + bool showOptions = false; + bool showSlider = true; + bool isLoading = false; + bool isProcessing = false; // Prevents any action until alert is dismissed + DateTime? lastFailureTime; // Track last failure to prevent duplicate alerts + + final List cancelOptions = [ + 'Customer refused', + 'Not Reachable', + 'Switched Off', + 'Incorrect Location', + ]; + + final List deliveredOptions = [ + 'Pay Later', + 'Cash Payment', + 'QR Payment', + ]; + + @override + void initState() { + super.initState(); + _resetSheetState(); + } + + void _resetSheetState() { + selectedOption = ''; + showOptions = false; + showSlider = true; + isDeliveredSelected = true; + isProcessing = false; + lastFailureTime = null; + } + + bool _hasCollection() { + final raw = widget.delivery['collectionamt']; + if (raw == null) return false; + if (raw is num) return raw > 0; + final parsed = double.tryParse(raw.toString()) ?? 0.0; + return parsed > 0; + } + + double _getCollectionAmount() { + final raw = widget.delivery['collectionamt']; + if (raw == null) return 0.0; + if (raw is num) return raw.toDouble(); + return double.tryParse(raw.toString()) ?? 0.0; + } + + double _getDeliveryAmount() { + final raw = widget.delivery['deliveryamt']; + if (raw == null) return 0.0; + if (raw is num) return raw.toDouble(); + return double.tryParse(raw.toString()) ?? 0.0; + } + + @override + Widget build(BuildContext context) { + // For delivered orders with collection amount, show custom payment choices + final options = isDeliveredSelected + ? (_hasCollection() + ? [ + 'Cash (₹${_getCollectionAmount().toStringAsFixed(0)})', + 'UPI (₹${_getCollectionAmount().toStringAsFixed(0)})', + 'Not Collected', + ] + : deliveredOptions) + : cancelOptions; + final Color confirmColor = isDeliveredSelected + ? ColorConstants.primaryColor + : Colors.red; + + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + top: false, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Update Delivery Status', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + InkWell( + onTap: () { + if (!showSlider && showOptions) { + setState(() => _resetSheetState()); + } else { + Navigator.pop(context); + } + }, + child: const Icon( + Icons.cancel, + color: Colors.red, + size: 30, + ), + ), + ], + ), + const SizedBox(height: 10), + const Divider(), + if (!showOptions && showSlider) ...[ + _buildCustomerInfo(), + const SizedBox(height: 10), + ], + if (showSlider) + Slidable( + key: ValueKey(widget.delivery['deliveryid']), + startActionPane: ActionPane( + motion: const ScrollMotion(), + extentRatio: + 0.9, // Allocate 90% of width for the 3 actions to prevent truncation + children: [ + SlidableAction( + borderRadius: BorderRadius.circular(6), + onPressed: (_) { + setState(() { + isDeliveredSelected = true; + selectedOption = ''; + showOptions = true; + showSlider = false; + isProcessing = false; // Reset when switching + lastFailureTime = null; // Clear failure cache + }); + }, + backgroundColor: Colors.green, + foregroundColor: Colors.white, + icon: Icons.check_circle, + label: 'Delivered', + ), + SlidableAction( + borderRadius: BorderRadius.circular(6), + onPressed: (_) { + setState(() { + isDeliveredSelected = false; + selectedOption = ''; + showOptions = true; + showSlider = false; + isProcessing = false; // Reset when switching + lastFailureTime = null; // Clear failure cache + }); + }, + backgroundColor: Colors.red, + foregroundColor: Colors.white, + icon: Icons.cancel, + label: 'Cancelled', + ), + SlidableAction( + borderRadius: BorderRadius.circular(6), + onPressed: (c) async { + // Resolve parent state reliably + final resolvedParentState = + widget.parentState ?? + context + .findAncestorStateOfType< + _MyDeliveriesState + >(); + + // Open skip sheet on TOP of this sheet (safer context usage) + // We await it so we know when it closes + await _showMyOptionsSheet( + context, + widget.delivery, + resolvedParentState, + ); + + // Close this update sheet after skip sheet is done + // Pass 'true' to indicate that an action *might* have been taken + // We could potentially return result from _showMyOptionsSheet but currently it returns void + // The skip sheet handles the logic, so we can assume if we return here we are done. + // Actually, we should only return true if the skip sheet *actually* skipped. + // Since _showMyOptionsSheet returns Future, we can't know for sure. + // BUT, since the user interacted with the skip sheet and we are closing this sheet, + // passing 'true' is safer to trigger a refresh check upstream. + if (mounted) { + Navigator.pop(context, true); + } + }, + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + icon: Icons.skip_next, + label: 'Skip', + ), + ], + ), + child: Container( + height: 65, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.swipe, color: Colors.black), + SizedBox(width: 10), + Text( + 'Slide to Update Delivery', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 5), + if (showOptions) ...[ + ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ...options.map((option) { + final bool isSelected = option == selectedOption; + return GestureDetector( + onTap: () { + if (isLoading || isProcessing) return; + setState(() { + selectedOption = option; + lastFailureTime = null; // Clear on new selection + }); + }, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + border: Border.all( + color: isSelected + ? confirmColor + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 18, + color: isSelected + ? confirmColor + : Colors.black, + fontFamily: FontConstants.fontFamily, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + Icon( + isSelected + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: isSelected ? confirmColor : Colors.grey, + ), + ], + ), + ), + ); + }), + ], + ), + ], + const SizedBox(height: 15), + // Slider enabled when a menu option is chosen. + if (selectedOption.isNotEmpty) + SliderButton( + properties: SliderButtonProperties( + height: 55, + buttonSize: 50, + width: MediaQuery.of(context).size.width - 40, + backgroundColor: confirmColor, + dismissThresholds: 0.90, + + /// ACTION + action: () async { + // Prevent action if already processing + if (isLoading || isProcessing) { + debugPrint( + '[SLIDER] ⚠️ Already processing, ignoring slide action', + ); + return false; + } + // _handleConfirm manages isLoading and isProcessing internally + await _handleConfirm(); + return false; // required by slider_button_lite + }, + + /// LABEL + label: Text( + isDeliveredSelected + ? "Slide to Confirm Delivery" + : "Slide to Confirm Cancellation", + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + + /// SLIDER BUTTON (Icon or Loader) + icon: ClipOval( + child: Material( + color: Colors.white, + child: SizedBox( + width: 50, + height: 50, + child: Center( + child: isLoading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + confirmColor, + ), + ), + ) + : Icon( + isDeliveredSelected + ? Icons.check + : Icons.close, + size: 28, + color: confirmColor, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildCustomerInfo() { + final pickupCustomer = (widget.delivery['pickupcustomer'] ?? '') + .toString() + .trim(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + 'Name:', + widget.delivery['deliverycustomer']?.toString() ?? 'Customer', + ), + const SizedBox(height: 10), + if (pickupCustomer.isNotEmpty) ...[ + _buildInfoRow('Kitchen:', pickupCustomer), + const SizedBox(height: 10), + ], + _buildInfoRow( + 'Address:', + widget.delivery['deliveryaddress']?.toString() ?? + 'Address not available', + isExpanded: true, + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Order ID:', + '#${widget.delivery['orderid']?.toString() ?? ''}', + ), + ], + ); + } + + Widget _buildInfoRow(String title, String value, {bool isExpanded = false}) { + final textWidget = Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + fontFamily: FontConstants.fontFamily, + ), + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$title ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(width: 10), + isExpanded ? Expanded(child: textWidget) : Flexible(child: textWidget), + ], + ); + } + + bool _wasSkippedDelivery( + Map delivery, + _MyDeliveriesState? parentState, + ) { + final status = + (delivery['orderstatus'] ?? + delivery['status'] ?? + delivery['orderStatus'] ?? + '') + .toString() + .toLowerCase(); + if (status == 'skipped') return true; + if (parentState == null) return false; + final key = parentState._getOrderKey(delivery); + return parentState._skippedOrdersCache.containsKey(key); + } + + Future _handleConfirm() async { + // CRITICAL: Prevent any re-entry during processing or alert display + if (isLoading || isProcessing) { + debugPrint( + '[CONFIRM] ⚠️ Already processing or alert showing, ignoring tap', + ); + return; + } + + // Check if we recently had a failure (within last 4 seconds) + // This prevents cached/duplicate alerts from showing + if (lastFailureTime != null) { + final timeSinceLastFailure = DateTime.now().difference(lastFailureTime!); + if (timeSinceLastFailure.inSeconds < 4) { + debugPrint( + '[CONFIRM] ⚠️ Too soon after last failure (${timeSinceLastFailure.inSeconds}s), ignoring tap', + ); + return; + } + } + + final d = widget.delivery; + debugPrint('[CONFIRM] ===== DELIVERY INFO ====='); + debugPrint('[CONFIRM] deliveryid: ${d['deliveryid']}'); + debugPrint('[CONFIRM] orderid: ${d['orderid']}'); + debugPrint('[CONFIRM] orderstatus: ${d['orderstatus']}'); + + final parentState = + widget.parentState ?? + context.findAncestorStateOfType<_MyDeliveriesState>(); + final orderId = (d['orderid'] ?? '').toString(); + final deliveryId = (d['deliveryid'] ?? '').toString(); + + final bool wasSkipped = _wasSkippedDelivery(d, parentState); + + if (parentState != null) { + final key = parentState._getOrderKey(d); + debugPrint('[CONFIRM] Generated key: $key'); + debugPrint( + '[CONFIRM] Is in cache: ${parentState._skippedOrdersCache.containsKey(key)}', + ); + debugPrint( + '[CONFIRM] All cache keys: ${parentState._skippedOrdersCache.keys.toList()}', + ); + } + debugPrint('[CONFIRM] wasSkipped=$wasSkipped'); + debugPrint('[CONFIRM] =========================='); + + // Set BOTH flags to block all interactions + if (mounted) { + setState(() { + isLoading = true; + isProcessing = true; + }); + } + + try { + final dc = Get.put(DeliveriesController(), permanent: true); + final int deliveryIdInt = int.tryParse('${d['deliveryid'] ?? 0}') ?? 0; + final int orderHeaderId = int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; + final int deliveryLocationId = + int.tryParse('${d['deliverylocationid'] ?? 0}') ?? 0; + + bool apiSuccess = false; + + if (isDeliveredSelected) { + // Delivery amount should come from API (deliveryamt), not from the + // selected payment option text (Cash/UPI), to avoid conflicts with + // collection amounts. + double deliveryAmount = _getDeliveryAmount(); + + // Fallback: if API did not send deliveryamt, keep old behavior of + // parsing a numeric value from the selected option (if any). + if (deliveryAmount == 0.0 && selectedOption.isNotEmpty) { + final m = RegExp(r'[0-9]+(?:\.[0-9]+)?').firstMatch(selectedOption); + if (m != null) { + deliveryAmount = double.tryParse(m.group(0)!) ?? 0.0; + } + } + + // Collection amounts from API (if any) + final double apiCollectionAmt = _getCollectionAmount(); + double collectionAmtToSend = 0.0; + double collectedAmtToSend = 0.0; + int collectionStatusToSend = 0; // 1 = cash, 2 = UPI, 3 = not collected + + if (apiCollectionAmt > 0) { + collectionAmtToSend = apiCollectionAmt; + final lower = selectedOption.toLowerCase(); + if (lower.startsWith('cash')) { + collectedAmtToSend = apiCollectionAmt; + collectionStatusToSend = 1; // Cash + } else if (lower.startsWith('upi')) { + collectedAmtToSend = apiCollectionAmt; + collectionStatusToSend = 2; // UPI + } else { + collectedAmtToSend = 0.0; + collectionStatusToSend = 3; // Not Collected + } + } + + double parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + final pickupLat = parseD(d['pickuplat'] ?? d['PickupLat'] ?? 0); + final pickupLng = parseD(d['pickuplon'] ?? d['PickupLon'] ?? 0); + final deliveryLat = parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'] ?? 0, + ); + final deliveryLng = parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'] ?? 0, + ); + + // Start fetching location in background while user takes photo + final locationFuture = + Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 5), + ), + ).then((p) => p).catchError((e) { + debugPrint('[DELIVER] Error getting rider location: $e'); + return null; + }); + + debugPrint( + '[DELIVER][CONFIRM] deliveryId=$deliveryIdInt orderHeaderId=$orderHeaderId deliveryLocationId=$deliveryLocationId deliveryAmount=$deliveryAmount notes="$selectedOption"', + ); + + // TRIGGER CAMERA - Proof of Delivery + // We do this concurrently with location fetching to save time + String? proofImageUrl; + + try { + final ImagePicker picker = ImagePicker(); + // Optimize image: Resize to max 1024x1024 and 50% quality for faster upload + final XFile? photo = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 50, + maxWidth: 1024, + maxHeight: 1024, + ); + + if (photo == null) { + // User cancelled camera, abort delivery confirmation + if (mounted) { + setState(() { + isLoading = false; + isProcessing = false; + }); + } + debugPrint('[CONFIRM] Camera cancelled by user'); + return; + } + + // Upload Proof + // By now, location fetching should be nearly done + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? 0; + if (userId > 0) { + proofImageUrl = await dc.uploadProofImage( + File(photo.path), + 'delivered', + userId, + deliveryIdInt, + ); + } + } catch (e) { + debugPrint('[CONFIRM] Camera/Upload error: $e'); + } + + // Now wait for location if it hasn't finished yet + String riderLatStr = '0'; + String riderLngStr = '0'; + final position = await locationFuture; + riderLatStr = position.latitude.toStringAsFixed(6); + riderLngStr = position.longitude.toStringAsFixed(6); + + // Validate coordinates before API call + final double riderLat = double.tryParse(riderLatStr) ?? 0.0; + final double riderLng = double.tryParse(riderLngStr) ?? 0.0; + final bool hasValidRiderLocation = + riderLat != 0 && + riderLng != 0 && + riderLat.abs() <= 90 && + riderLng.abs() <= 180; + final bool hasValidDeliveryLocation = + deliveryLat != 0 && + deliveryLng != 0 && + deliveryLat.abs() <= 90 && + deliveryLng.abs() <= 180; + + if (!hasValidRiderLocation || !hasValidDeliveryLocation) { + debugPrint( + '[DELIVER][CONFIRM] ⚠️ Invalid coordinates - Rider: ($riderLat, $riderLng), Delivery: ($deliveryLat, $deliveryLng)', + ); + if (mounted) { + setState(() { + isLoading = false; + }); + } + // Show error alert + Get.snackbar( + 'Location Error', + 'Unable to get your location. Please ensure GPS is enabled and try again.', + backgroundColor: Colors.red, + colorText: Colors.white, + duration: const Duration(seconds: 4), + snackPosition: SnackPosition.TOP, + isDismissible: true, + ); + lastFailureTime = DateTime.now(); + await Future.delayed(const Duration(seconds: 4)); + if (mounted) { + setState(() { + isProcessing = false; + }); + } + return; + } + + if (proofImageUrl == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Failed to upload proof image. Please try again.', + ), + ), + ); + setState(() { + isLoading = false; + isProcessing = false; + }); + } + return; + } + + apiSuccess = await dc.updateDeliveredStatus( + deliveryId: deliveryIdInt, + orderHeaderId: orderHeaderId, + deliveryLocationId: deliveryLocationId, + smsDelivery: 0, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + deliveryLat: deliveryLat.toStringAsFixed(6), + deliveryLng: deliveryLng.toStringAsFixed(6), + notes: selectedOption, + deliveryAmount: deliveryAmount, + collectionAmount: collectionAmtToSend, + collectedAmount: collectedAmtToSend, + collectionStatus: collectionStatusToSend, + wasSkipped: wasSkipped, + orderId: orderId, // ✅ Pass orderId for bonus points logic + proofImage: proofImageUrl, + ); + debugPrint('[DELIVER][CONFIRM] result=$apiSuccess'); + + if (apiSuccess) { + // On success, cache and clean up before navigating to done screen + await _cacheDeliveredCancelled( + deliveryId, + orderId, + isDelivered: true, + ); + await _cleanupAfterCompletion( + orderId, + deliveryId, + parentState, + isCancelled: false, + ); + if (!mounted) return; + + if (!mounted) return; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + DeliveriesDone(bonusPoints: dc.lastBonusPoints.value), + ), + ); + return; + } + } else { + double parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + final pickupLat = parseD(d['pickuplat'] ?? d['PickupLat'] ?? 0); + final pickupLng = parseD(d['pickuplon'] ?? d['PickupLon'] ?? 0); + + String riderLatStr = '0'; + String riderLngStr = '0'; + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 5), + ), + ); + riderLatStr = position.latitude.toStringAsFixed(6); + riderLngStr = position.longitude.toStringAsFixed(6); + } catch (e) { + debugPrint('[CANCEL] Error getting rider location: $e'); + } + + // Validate coordinates before API call + final double riderLat = double.tryParse(riderLatStr) ?? 0.0; + final double riderLng = double.tryParse(riderLngStr) ?? 0.0; + final bool hasValidRiderLocation = + riderLat != 0 && + riderLng != 0 && + riderLat.abs() <= 90 && + riderLng.abs() <= 180; + + // For cancellation, we need delivery location from the delivery data + final double cancelDeliveryLat = parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'] ?? 0, + ); + final double cancelDeliveryLng = parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'] ?? 0, + ); + final bool hasValidDeliveryLocation = + cancelDeliveryLat != 0 && + cancelDeliveryLng != 0 && + cancelDeliveryLat.abs() <= 90 && + cancelDeliveryLng.abs() <= 180; + + if (!hasValidRiderLocation || !hasValidDeliveryLocation) { + debugPrint( + '[CANCEL][CONFIRM] ⚠️ Invalid coordinates - Rider: ($riderLat, $riderLng), Delivery: ($cancelDeliveryLat, $cancelDeliveryLng)', + ); + if (mounted) { + setState(() { + isLoading = false; + }); + } + // Show error alert + Get.snackbar( + 'Location Error', + 'Unable to get your location. Please ensure GPS is enabled and try again.', + backgroundColor: Colors.red, + colorText: Colors.white, + duration: const Duration(seconds: 4), + snackPosition: SnackPosition.TOP, + isDismissible: true, + ); + lastFailureTime = DateTime.now(); + await Future.delayed(const Duration(seconds: 4)); + if (mounted) { + setState(() { + isProcessing = false; + }); + } + return; + } + + apiSuccess = await dc.updateCancelledStatus( + deliveryId: deliveryIdInt, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + deliveryLat: cancelDeliveryLat.toStringAsFixed(6), + deliveryLng: cancelDeliveryLng.toStringAsFixed(6), + notes: selectedOption, + wasSkipped: wasSkipped, + ); + debugPrint('[CANCEL][CONFIRM] result=$apiSuccess'); + + if (apiSuccess) { + // On success, cache and clean up before navigating to done screen + await _cacheDeliveredCancelled( + deliveryId, + orderId, + isDelivered: false, + ); + await _cleanupAfterCompletion( + orderId, + deliveryId, + parentState, + isCancelled: true, + ); + if (!mounted) return; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => const DeliveriesDone(isCancelled: true), + ), + ); + return; + } + } + + // If API failed, record the failure time to prevent duplicate alerts + lastFailureTime = DateTime.now(); + debugPrint( + '[CONFIRM] ⚠️ API failed at $lastFailureTime, waiting for alert to dismiss...', + ); + + // Stop the loading spinner immediately + if (mounted) { + setState(() { + isLoading = false; + }); + } + + // Wait for geofence/error snackbar to show and be dismissed + // The controller's _checkGeofence already shows the snackbar, so we just wait + // Don't show duplicate fallback error - controller already shows error message + await Future.delayed(const Duration(seconds: 3)); + + // Now re-enable the button + if (mounted) { + setState(() { + isProcessing = false; + }); + debugPrint('[CONFIRM] ✅ Button re-enabled after alert dismissal'); + } + } catch (e) { + debugPrint('[CONFIRM] ❌ Exception during confirm: $e'); + + // Record failure time + lastFailureTime = DateTime.now(); + + // Stop loading spinner + if (mounted) { + setState(() { + isLoading = false; + }); + } + + // Wait for potential error alert to dismiss (4 seconds) + await Future.delayed(const Duration(seconds: 4)); + + // Re-enable button + if (mounted) { + setState(() { + isProcessing = false; + }); + debugPrint('[CONFIRM] ✅ Button re-enabled after exception handling'); + } + } + } + + Future _cacheDeliveredCancelled( + String deliveryId, + String orderId, { + required bool isDelivered, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + if (userId > 0) { + final key = 'recently_delivered_cancelled_$userId'; + final deliveredCancelledJson = prefs.getString(key); + List deliveredCancelledList = []; + if (deliveredCancelledJson != null && + deliveredCancelledJson.isNotEmpty) { + deliveredCancelledList = jsonDecode(deliveredCancelledJson); + } + + deliveredCancelledList.add({ + 'deliveryId': deliveryId, + 'orderId': orderId, + 'timestamp': DateTime.now().toIso8601String(), + 'type': isDelivered ? 'delivered' : 'cancelled', + }); + + await prefs.setString(key, jsonEncode(deliveredCancelledList)); + debugPrint( + '[${isDelivered ? 'DELIVER' : 'CANCEL'}] ✅ Stored order in SharedPreferences: deliveryId=$deliveryId, orderId=$orderId', + ); + } + } catch (e) { + debugPrint( + '[${isDelivered ? 'DELIVER' : 'CANCEL'}] Error storing order in SharedPreferences: $e', + ); + } + } + + Future _cleanupAfterCompletion( + String orderId, + String deliveryId, + _MyDeliveriesState? parentState, { + required bool isCancelled, + }) async { + final label = isCancelled ? 'CANCEL' : 'DELIVER'; + if (orderId.isEmpty) { + debugPrint('[$label] ⚠️ Empty orderId, skipping cleanup'); + return; + } + + if (parentState == null) { + debugPrint( + '[$label] ⚠️ parentState null; relying on cache cleanup via SharedPreferences', + ); + // Try to find and stop timer from cart page if it exists + // This is a workaround - ideally we'd pass a callback + try { + // The cart page will handle timer cleanup on next refresh + debugPrint('[$label] Cart page will cleanup timer on next refresh'); + } catch (_) {} + return; + } + + parentState._stopDeliveryPosting(orderId); + debugPrint('[$label] Stopped timer for completed delivery: $orderId'); + + final deliveryKey = 'delivery_$deliveryId'; + final orderKey = 'order_$orderId'; + final actualKey = parentState._getOrderKey(widget.delivery); + + bool cacheUpdated = false; + + void removeKey(String key, String reason) { + if (parentState._skippedOrdersCache.remove(key) != null) { + parentState._skippedOrderTimestamps.remove(key); + cacheUpdated = true; + debugPrint('[$label] ✅ Removed from cache ($reason): $key'); + } + } + + removeKey(deliveryKey, 'delivery key'); + removeKey(orderKey, 'order key'); + removeKey(actualKey, 'actual key'); + + if (!cacheUpdated) { + final keysToRemove = []; + // NOTE: We rely on the explicitly constructed keys above to remove entries. + // Iterating via entry values isn't supported with Map cache. + // If items were not found by keys above, they remain until valid keys are used. + + for (final key in keysToRemove) { + parentState._skippedOrdersCache.remove(key); + parentState._skippedOrderTimestamps.remove(key); + cacheUpdated = true; + debugPrint('[$label] ✅ Removed from cache (matched by ID): $key'); + } + } + + if (cacheUpdated) { + await parentState._saveSkippedOrdersCache(); + debugPrint( + '[$label] ✅ Cache saved. Remaining: ${parentState._skippedOrdersCache.length}', + ); + } else { + debugPrint( + '[$label] ⚠️ Order not found in skipped cache (cleanup will run on next fetch)', + ); + } + } +} diff --git a/lib/xpress/views/Dashboard/deliveries/skip_sheet.dart b/lib/xpress/views/Dashboard/deliveries/skip_sheet.dart new file mode 100644 index 0000000..c0d45df --- /dev/null +++ b/lib/xpress/views/Dashboard/deliveries/skip_sheet.dart @@ -0,0 +1,430 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// SKIP REASONS SHEET +// ------------------------------------------------------------------------- +Future _showMyOptionsSheet( + BuildContext context, + Map delivery, + _MyDeliveriesState? parentState, +) async { + int selected = -1; + bool isLoading = false; + + final List skipReasons = [ + 'Customer unreachable', + 'Customer not at the location', + ]; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setState) { + Widget optionBox(String title, int index) { + final bool isSelected = selected == index; + + return GestureDetector( + onTap: () { + if (!isLoading) { + setState(() => selected = index); + } + }, + child: Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: isSelected + ? ColorConstants.primaryColor.withOpacity(0.1) + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? ColorConstants.primaryColor + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Icon( + isSelected ? Icons.check_circle : Icons.circle_outlined, + color: isSelected + ? ColorConstants.primaryColor + : Colors.grey, + size: 28, + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + Future handleConfirm() async { + if (selected == -1 || isLoading) return; + + // Check skip limits before proceeding + final dc = Get.put(DeliveriesController(), permanent: true); + final skipStatus = await dc.checkSkipStatus(); + final int skipCount = skipStatus['count'] ?? 0; + bool applyPenalty = false; + + if (skipCount >= 2) { + // Show Styled Alert Dialog + final bool? confirm = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + elevation: 5, + backgroundColor: Colors.white, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_amber_rounded, + color: Colors.red.shade600, + size: 40, + ), + ), + const SizedBox(height: 20), + Text( + 'Skip Limit Reached', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Text( + 'You have exceeded the limit of 2 skips within 3 hours.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontFamily: FontConstants.fontFamily, + color: Colors.black54, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.orange.shade200, + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: Colors.orange.shade800, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Proceeding will forfeit your bonus points for this session.", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.orange.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => + Navigator.pop(context, false), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 14, + ), + side: BorderSide( + color: Colors.grey.shade300, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "Cancel", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () => + Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + padding: const EdgeInsets.symmetric( + vertical: 14, + ), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "Confirm Skip", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + + if (confirm != true) return; // User cancelled or dismissed + applyPenalty = true; + } + + setState(() => isLoading = true); + + try { + final dc = Get.put(DeliveriesController(), permanent: true); + final d = delivery; + final int deliveryId = + int.tryParse('${d['deliveryid'] ?? 0}') ?? 0; + final int orderHeaderId = + int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; + final String reason = + selected >= 0 && selected < skipReasons.length + ? skipReasons[selected] + : ''; + + if (deliveryId > 0 && orderHeaderId > 0) { + String riderLatStr = '0'; + String riderLngStr = '0'; + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 5), + ); + riderLatStr = position.latitude.toStringAsFixed(6); + riderLngStr = position.longitude.toStringAsFixed(6); + } catch (e) { + debugPrint('[SKIP] Error getting rider location: $e'); + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + riderLatStr = lastPos.latitude.toStringAsFixed(6); + riderLngStr = lastPos.longitude.toStringAsFixed(6); + } + } catch (_) {} + } + + debugPrint( + '[SKIP] Updating status for deliveryId=$deliveryId orderHeaderId=$orderHeaderId reason="$reason"', + ); + + final ok = await dc.updateSkippedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + notes: reason, + ); + + debugPrint('[SKIP] Status update result: $ok'); + + if (ok && context.mounted) { + // Register skip locally only on success + await dc.registerSkip(applyPenalty: applyPenalty); + + if (context.mounted) { + Navigator.pop(context); // Close the sheet + + if (parentState != null) { + // Only update list if called from the list view + parentState.markOrderAsSkipped(d, reason); + } else { + // If called from Navigation/Map (where parentState is null), + // we just close the sheet and let the caller handle the UI update + // typically by popping the route or showing a snackbar. + // We DO NOT force navigation to "MyDeliveries". + debugPrint( + '[SKIP] Skipped from Nav/Map screen, sheet closed.', + ); + } + } + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Failed to skip delivery. Please try again.', + ), + backgroundColor: Colors.red, + ), + ); + } + } else { + debugPrint( + '[SKIP] Invalid deliveryId: $deliveryId or orderHeaderId: $orderHeaderId', + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid delivery information.'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + debugPrint('[SKIP] Error updating skip status: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('An error occurred. Please try again.'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (context.mounted) { + setState(() => isLoading = false); + } + } + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 30), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Select Reason', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + IconButton( + icon: const Icon( + Icons.cancel, + color: Colors.red, + size: 32, + ), + onPressed: isLoading + ? null + : () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 20), + optionBox('Customer unreachable', 0), + optionBox('Customer not at the location', 1), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: (selected == -1 || isLoading) + ? Colors.grey.shade300 + : ColorConstants.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: (selected == -1 || isLoading) + ? null + : handleConfirm, + child: isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + 'Confirm', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: (selected == -1 || isLoading) + ? Colors.black45 + : Colors.white, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); +} diff --git a/lib/xpress/views/Dashboard/home/homepage.dart b/lib/xpress/views/Dashboard/home/homepage.dart new file mode 100644 index 0000000..debff5b --- /dev/null +++ b/lib/xpress/views/Dashboard/home/homepage.dart @@ -0,0 +1,3105 @@ +// ignore_for_file: unused_element, unused_import + +import 'package:flutter/material.dart'; +import 'dart:convert'; +import 'dart:async'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:miler/xpress/providers/delivery/delivery_provider.dart'; +import 'package:miler/xpress/providers/deliverylog/deliverylog_provider.dart'; +import 'package:miler/xpress/views/Dashboard/orders/orderstaus_button.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:miler/xpress/controllers/profile_controller.dart'; +import 'package:miler/xpress/controllers/riderlog.dart'; +import 'package:miler/xpress/controllers/logcontroller.dart'; +import 'package:miler/xpress/controllers/delivery.dart'; +import 'package:miler/xpress/controllers/deliveries_controller.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:slide_to_submit_button/slide_to_submit_button.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:miler/xpress/services/maps/map_marker_icons.dart'; +import 'package:miler/xpress/services/maps/doormile_map.dart'; +import 'package:miler/xpress/services/maps/routing_service.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:floating/floating.dart'; +import 'dart:math' as math; +import 'package:http/http.dart' as http; +import 'package:miler/xpress/views/Dashboard/home/homepage_banner.dart'; +import 'package:miler/xpress/views/Dashboard/deliveries/deliveries.dart' + as deliveries; +import 'package:miler/xpress/background/live_tracking_service.dart'; +import 'package:miler/xpress/data/delivery_api.dart'; + +/// Helper function to launch phone dialer - works in both debug and release builds +/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch +Future launchPhoneDialer(String phoneNumber) async { + try { + // Sanitize phone number: keep only digits and '+' + final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), ''); + + if (phone.isEmpty) { + debugPrint( + '[PHONE] Empty phone number after sanitization, skipping dial', + ); + return false; + } + + final Uri uri = Uri(scheme: 'tel', path: phone); + + // Try canLaunchUrl first (works in debug, may fail in release) + bool canLaunch = false; + try { + canLaunch = await canLaunchUrl(uri); + debugPrint('[PHONE] canLaunchUrl result: $canLaunch'); + } catch (e) { + debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e'); + // Continue anyway - launch might still work + } + + // Always attempt to launch, even if canLaunchUrl returned false + // Using LaunchMode.platformDefault is often safer for system intents like dialing + try { + final launched = await launchUrl(uri, mode: LaunchMode.platformDefault); + if (launched) { + debugPrint('[PHONE] Successfully launched dialer for: $phone'); + return true; + } else { + debugPrint('[PHONE] launchUrl returned false for: $phone'); + } + } catch (e) { + debugPrint('[PHONE] Failed to launch dialer: $e'); + } + return false; + } catch (e) { + debugPrint('[PHONE] Error in launchPhoneDialer: $e'); + return false; + } +} + +class Homepage extends StatefulWidget { + const Homepage({super.key}); + @override + State createState() => _HomepageState(); +} + +class _HomepageState extends State + with AutomaticKeepAliveClientMixin { + bool isOnline = true; + String _userName = ''; + String _shiftStart = ''; + String _shiftEnd = ''; + final DeliveryProvider _delivery = DeliveryProvider(); + final DeliveryController _deliveryController = Get.find(); + + // Core data - using Map with orderId as key for stable tracking + Map> _ordersMap = {}; + List _orderIds = []; // Maintain order + + StreamSubscription? _pollerSubscription; + Duration _currentPollInterval = const Duration(seconds: 0); + bool _isToggling = false; + + // Selection tracking + Map _selectedOrders = {}; + bool isAllSelected = false; + + String _lastQueuesJson = ''; + bool _loadedOnlineFromPrefs = false; + bool _isFetchingQueues = false; + + final Map _deliveryRunning = {}; + final Map _deliveryTimers = {}; + final Map> _deliveryBasePayload = + >{}; + final CreateDeliveryLogProvider _deliveryLogProvider = + CreateDeliveryLogProvider(); + // Prevent optimistic status flips; disable row while updating + final Map _statusBusy = {}; + + Timer? _locationTimer; + final Set _hiddenOrderIds = {}; + + // Active delivery tracking + List> _activeDeliveries = >[]; + + @override + void dispose() { + // Stop any active delivery posting timers + final keys = List.from(_deliveryTimers.keys); + for (final k in keys) { + _stopDeliveryPosting(k); + } + _pollerSubscription?.cancel(); + _locationTimer?.cancel(); + super.dispose(); + } + + void _stopDeliveryPosting(String orderId) { + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + } + + // Helper to get order status from API data + String _getStatusFromOrder(Map order) { + final apiStatus = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + if (apiStatus == 'accepted') return 'ACCEPTED'; + if (apiStatus == 'arrived') return 'ARRIVED'; + if (apiStatus == 'picked') return 'PICKED'; + return 'ACCEPT'; // Default for pending/new orders + } + + // Parse double safely + double _parseDouble(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + // Temporarily disabled: always allow (prox check will be re-enabled later) + Future _isNearPickupLocation(Map order) async { + try { + final pickupLat = _parseDouble(order['pickuplat'] ?? order['PickupLat']); + final pickupLng = _parseDouble(order['pickuplon'] ?? order['PickupLon']); + + if (pickupLat == 0 || pickupLng == 0) { + debugPrint('[PROXIMITY] Invalid pickup coordinates, skipping check.'); + return true; // Assume near if coordinates are missing + } + + final riderLoc = await _getValidCoordinates(); + if (riderLoc == null) { + debugPrint('[PROXIMITY] Could not get rider location, skipping check.'); + return true; // Assume near if location fails (don't block) + } + + final double riderLat = double.tryParse(riderLoc.$1) ?? 0; + final double riderLng = double.tryParse(riderLoc.$2) ?? 0; + + if (riderLat == 0 || riderLng == 0) { + return true; + } + + final double distanceInMeters = Geolocator.distanceBetween( + riderLat, + riderLng, + pickupLat, + pickupLng, + ); + + debugPrint( + '[PROXIMITY] Distance: ${distanceInMeters.toStringAsFixed(2)}m (Threshold: 500m)', + ); + + if (distanceInMeters > 500) { + return false; + } + + return true; + } catch (e) { + debugPrint('[PROXIMITY] Error checking proximity: $e'); + return true; // Fail safe + } + } + + // Check proximity for multiple orders + Future> _checkProximityForOrders( + List orderIds, + ) async { + final Map results = {}; + + for (final orderId in orderIds) { + final order = _ordersMap[orderId]; + if (order == null) { + results[orderId] = false; + continue; + } + + results[orderId] = await _isNearPickupLocation(order); + } + + return results; + } + + // Show proximity warning dialog + Future _showProximityWarning( + BuildContext context, { + String? specificMessage, + }) async { + return showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + const Icon(Icons.location_off, color: Colors.red, size: 28), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Not at Pickup Location', + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + content: Text( + specificMessage ?? + 'You must be within 500 meters of the pickup location to mark this order as arrived. Please move closer and try again.', + style: TextStyle( + fontSize: FontConstants.regular(context), + fontFamily: FontConstants.fontFamily, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + style: TextButton.styleFrom( + foregroundColor: ColorConstants.primaryColor, + ), + child: Text( + 'OK', + style: TextStyle( + fontSize: FontConstants.regular(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + Future<(String lat, String lng)?> _getValidCoordinates() async { + try { + await _ensureLocationPermission(); + Position? pos; + try { + // Use lower accuracy with a shorter timeout for faster loading + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 3), + ).timeout(const Duration(seconds: 3)); + } catch (_) { + pos = await Geolocator.getLastKnownPosition(); + } + if (pos != null) { + final lat = pos.latitude.toStringAsFixed(6); + final lng = pos.longitude.toStringAsFixed(6); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + return (lat, lng); + } + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') { + return (lat, lng); + } + } catch (_) {} + return null; + } catch (_) { + return null; + } + } + + Future _getLogInterval() async { + try { + final prefs = await SharedPreferences.getInstance(); + final secs = prefs.getInt('logseconds'); + final int interval = (secs != null && secs > 0) ? secs : 30; + return Duration(seconds: interval); + } catch (_) { + return const Duration(seconds: 30); + } + } + + @override + void initState() { + super.initState(); + // Run these in parallel for faster loading + _loadName(); + _ensureLocationPermission(); // Don't block on this + _ensureCameraPermission(); // Request camera permission at startup + _startPolling(); + + // Fetch queues immediately, don't wait for location + WidgetsBinding.instance.addPostFrameCallback((_) { + _fetchQueues(); + }); + + try { + if (Get.isRegistered()) { + final pc = Get.find(); + ever(pc.userName, (_) { + _loadName(); + }); + } + } catch (_) {} + } + + void _showLocationBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const Icon(Icons.location_off, size: 80, color: Colors.red), + const SizedBox(height: 16), + Text( + "Location is turned off", + style: TextStyle( + fontSize: FontConstants.xxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 12), + Text( + "Please turn on location to receive orders and track delivery.", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: FontConstants.regular(context), + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + Navigator.of(ctx).pop(); + await Geolocator.openLocationSettings(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text( + "Turn On Location", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Future _getAndUpdateCurrentLocation() async { + bool serviceEnabled; + LocationPermission permission; + + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _showLocationBottomSheet(); + return; + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + debugPrint("Location permissions are denied."); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + debugPrint("Location permissions are permanently denied."); + return; + } + + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + // ✅ OPTIMIZATION: Only geocode if moved significantly (> 100m) + // Reverse geocoding is expensive and slow + if (_lastGeocodedPosition != null) { + final dist = Geolocator.distanceBetween( + _lastGeocodedPosition!.latitude, + _lastGeocodedPosition!.longitude, + position.latitude, + position.longitude, + ); + if (dist < 100) { + // Hasn't moved enough to change address, skip + return; + } + } + + try { + List placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + + if (placemarks.isNotEmpty) { + _lastGeocodedPosition = position; // Save for next check + Placemark place = placemarks.first; + + String fullAddress = + "${place.name}, ${place.subLocality}, ${place.locality}, ${place.administrativeArea}, ${place.country}, ${place.postalCode}"; + String city = place.locality ?? ''; + String state = place.administrativeArea ?? ''; + String suburb = place.subLocality ?? ''; + + await _updateProfile( + address: fullAddress, + latitude: position.latitude, + longitude: position.longitude, + city: city, + state: state, + suburb: suburb, + ); + } + } catch (e) { + debugPrint("Error geocoding location: $e"); + } + } + + Future _updateProfile({ + required String address, + required double latitude, + required double longitude, + required String city, + required String state, + required String suburb, + }) async { + debugPrint( + 'Profile update → $address | $latitude,$longitude | $city,$state,$suburb', + ); + } + + void _startLocationUpdates() { + _locationTimer?.cancel(); + _locationTimer = Timer.periodic(const Duration(seconds: 30), (_) { + _getAndUpdateCurrentLocation(); + }); + } + + Future _ensureLocationPermission() async { + try { + final prefs = await SharedPreferences.getInstance(); + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + // Try last known position first + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + // Open settings in background, don't wait + Geolocator.openLocationSettings(); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + // Use last known position if available + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + return; + } + + // Use medium accuracy with timeout for faster initialization + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, // Changed from high + timeLimit: const Duration(seconds: 5), // Add timeout + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[HOMEPAGE] Timeout getting location, using last known: $e'); + pos = await Geolocator.getLastKnownPosition(); + } + + if (pos != null) { + await prefs.setString('last_lat', pos.latitude.toString()); + await prefs.setString('last_lng', pos.longitude.toString()); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error ensuring location permission: $e'); + // Try to save last known position as fallback + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + } catch (_) {} + } + } + + Future _ensureCameraPermission() async { + try { + final status = await Permission.camera.status; + if (status.isDenied) { + // We haven't asked yet or user denied previously but not forever + await Permission.camera.request(); + } else if (status.isPermanentlyDenied) { + // User denied forever, we might want to show a dialog or snackbar + // asking them to go to settings, but for startup let's just leave it + // so we don't annoy them every time if they really don't want to. + debugPrint('[HOMEPAGE] Camera permission permanently denied'); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error ensuring camera permission: $e'); + } + } + + void _confirmOnlineOffline() { + if (_isToggling) return; + final bool newStatus = !isOnline; + + if (!mounted) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext ctx) { + return StatefulBuilder( + builder: (ctx2, setSheetState) { + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx2).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon( + Icons.cancel, + color: Colors.red, + size: 32, + ), + onPressed: () { + if (Navigator.of(ctx2).canPop()) { + Navigator.of(ctx2).pop(); + } + }, + ), + ], + ), + Image.asset( + 'assets/images/Doormile Bike.png', + height: 140, + errorBuilder: (c, e, s) => const SizedBox.shrink(), + ), + const SizedBox(height: 16), + Text( + newStatus ? "Go Online?" : "Go Offline?", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 16), + Text( + newStatus + ? "You will start receiving orders." + : "You will stop receiving orders.", + style: TextStyle( + fontSize: FontConstants.large(context), + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: MediaQuery.of(context).size.width - 40, + child: SlideToSubmit.custom( + height: 55, + sliderWidth: 40, + padding: const EdgeInsets.all(8), + backgroundDecoration: BoxDecoration( + color: newStatus + ? ColorConstants.primaryColor.withOpacity(0.50) + : Colors.red.withOpacity(0.50), + borderRadius: BorderRadius.circular(40), + ), + foregroundDecoration: BoxDecoration( + color: newStatus + ? ColorConstants.primaryColor + : Colors.red, + borderRadius: BorderRadius.circular(999), + ), + slider: Center( + child: ClipOval( + child: Container( + height: 40, + width: 40, + color: Colors.white, // or any background color + padding: EdgeInsets.all(8), + child: const Icon( + Icons.arrow_forward_ios, + size: 28, + color: Colors.black, + ), + ), + ), + ), + hint: const Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: AnimatedSlideArrow( + arrowImage: AssetImage( + 'assets/arrow_right.png', + package: 'slide_to_submit_button', + ), + ), + ), + ), + onSubmit: (controller) async { + if (!mounted) return; + if (_isToggling) return; + + if (newStatus == false) { + // Check for any pending queues or live (picked) deliveries + bool hasPending = + _ordersMap.isNotEmpty || + _deliveryRunning.values.any((v) => v); + try { + final prefs = + await SharedPreferences.getInstance(); + final bool hasLive = + prefs.getBool('has_live_deliveries') ?? + false; + hasPending = hasPending || hasLive; + } catch (_) {} + if (hasPending) { + try { + await showDialog( + context: context, + builder: (dCtx) => AlertDialog( + title: const Text('Pending orders'), + content: const Text( + 'Please complete all orders before going offline.', + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(dCtx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } catch (_) {} + try { + controller.reset(); + } catch (_) {} + if (Navigator.of(ctx2).canPop()) { + Navigator.of(ctx2).pop(); + } + return; + } + } + + _isToggling = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogCtx) { + return const Center( + child: CircularProgressIndicator(), + ); + }, + ); + + bool ok = false; + try { + ok = await _handleOnlineToggle( + newStatus, + ).timeout(const Duration(seconds: 30)); + } catch (_) { + ok = false; + } finally { + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + _isToggling = false; + } + + if (!mounted) return; + setState(() { + isOnline = ok ? newStatus : isOnline; + }); + + try { + final prefs = + await SharedPreferences.getInstance(); + await prefs.setBool('online', isOnline); + } catch (_) {} + + if (mounted) { + Get.snackbar( + ok + ? (newStatus ? 'Online' : 'Offline') + : 'Error', + ok + ? (newStatus + ? 'You are now online.' + : 'You are now offline.') + : 'Something went wrong. Please try again.', + backgroundColor: Colors.black.withOpacity(0.6), + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(12), + borderRadius: 12, + duration: const Duration(seconds: 2), + ); + } + + if (ok && newStatus) { + _startLocationUpdates(); + LiveTrackingService().startTracking(); + _fetchQueues(); + _startPolling(); + } else if (!newStatus) { + _locationTimer?.cancel(); + LiveTrackingService().stopTracking(); + _fetchQueues(); + _startPolling(); + } + + try { + controller.reset(); + } catch (_) {} + if (Navigator.of(ctx2).canPop()) { + Navigator.of(ctx2).pop(); + } + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } + + Future _loadName() async { + String name = ''; + try { + if (Get.isRegistered()) { + final pc = Get.find(); + final val = pc.userName.value; + if (val.toString().trim().isNotEmpty) { + name = val.toString().trim(); + } + } + } catch (_) {} + + try { + final prefs = await SharedPreferences.getInstance(); + if (name.isEmpty) { + name = (prefs.getString('user_name') ?? '').trim(); + } + _shiftStart = (prefs.getString('starttime') ?? '').toString(); + _shiftEnd = (prefs.getString('endtime') ?? '').toString(); + final savedOnline = prefs.getBool('online'); + final int onduty = prefs.getInt('onduty') ?? -1; + + if (!mounted) return; + setState(() { + _userName = name; + if (!_loadedOnlineFromPrefs) { + if (onduty == 1) { + isOnline = true; + prefs.setBool('online', true); + // Ensure foreground logging service is running when already on duty + try { + if (Get.isRegistered()) { + Get.find().startLogging(); + } + } catch (_) {} + } else if (onduty == 0) { + isOnline = false; + prefs.setBool('online', false); + } else { + isOnline = savedOnline ?? true; + } + _loadedOnlineFromPrefs = true; + } + }); + } catch (_) { + if (!mounted) return; + setState(() { + _userName = name; + isOnline = true; + }); + } + } + + Future _handleOnlineToggle(bool online) async { + try { + if (!Get.isRegistered()) return false; + final ctl = Get.find(); + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userId') ?? prefs.getInt('userid'); + if (userId == null) return false; + + const String lat = '0'; + const String lng = '0'; + + if (online == true) { + debugPrint('[BREAK] Ending break... lat=$lat lng=$lng'); + final ok = await ctl + .endBreakAuto(latitude: lat, longitude: lng) + .timeout(const Duration(seconds: 12), onTimeout: () => false); + debugPrint('[BREAK] endBreakAuto -> $ok'); + final dutyOk = await ctl.setOnDuty(true); + debugPrint('[ONDUTY] setOnDuty(true) -> $dutyOk'); + await prefs.setBool('online', dutyOk); + } else { + debugPrint('[BREAK] Starting break... lat=$lat lng=$lng'); + final ok = await ctl + .startBreakAuto(latitude: lat, longitude: lng) + .timeout(const Duration(seconds: 12), onTimeout: () => false); + debugPrint('[BREAK] startBreakAuto -> $ok'); + final dutyOk = await ctl.setOnDuty(false); + debugPrint('[ONDUTY] setOnDuty(false) -> $dutyOk'); + await prefs.setBool('online', dutyOk ? false : true); + } + return true; + } catch (_) { + return false; + } + } + + void _startPolling() { + final interval = isOnline + ? const Duration(seconds: 5) + : const Duration(seconds: 20); + + if (_pollerSubscription != null && _currentPollInterval == interval) { + return; + } + + _pollerSubscription?.cancel(); + _currentPollInterval = interval; + + // Use Stream.periodic instead of Timer.periodic for better resource management + _pollerSubscription = Stream.periodic(interval, (_) {}) + .asyncMap((_) async { + if (mounted && !_isFetchingQueues) { + await _fetchQueues(); + } + }) + .listen( + (_) {}, // Success handler + onError: (error) { + // Handle errors gracefully without crashing + debugPrint('[HOMEPAGE][STREAM ERROR] $error'); + }, + cancelOnError: false, // Continue even on errors + ); + } + + Future _fetchQueues() async { + if (_isFetchingQueues) return; + + try { + _isFetchingQueues = true; + + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid'); + + if (userId == null) { + if (!mounted) return; + setState(() { + _ordersMap.clear(); + _orderIds.clear(); + _selectedOrders.clear(); + isAllSelected = false; + }); + return; + } + + final items = await _delivery.getDeliveryQueues( + live: true, + userid: userId, + ); + + if (!mounted) return; + + // ✅ Also fetch active deliveries from v2 API (same as deliveries page) + await _fetchActiveDeliveries(userId); + + final validOrders = items.whereType>().where((m) { + final status = (m['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final oid = (m['orderid'] ?? '').toString(); + + if (oid.isNotEmpty && _hiddenOrderIds.contains(oid)) return false; + // Exclude active orders from main list (they show in banner) + if (status == 'active') return false; + return status != 'picked'; + }).toList(); + + if (validOrders.isNotEmpty) { + final delivery = validOrders.first; + await _deliveryController.saveFromQueueItem(delivery); + } + + final newJson = jsonEncode(validOrders); + if (newJson == _lastQueuesJson) { + return; + } + _lastQueuesJson = newJson; + + final Map> newOrdersMap = {}; + final List newOrderIds = []; + + for (final order in validOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + newOrdersMap[orderId] = order; + newOrderIds.add(orderId); + } + + final Map newSelection = {}; + for (final orderId in newOrderIds) { + newSelection[orderId] = _selectedOrders[orderId] ?? false; + } + + setState(() { + _ordersMap = newOrdersMap; + _orderIds = newOrderIds; + _selectedOrders = newSelection; + + if (_selectedOrders.isEmpty) { + isAllSelected = false; + } else { + isAllSelected = _selectedOrders.values.every((selected) => selected); + } + + for (final orderId in _orderIds) { + if (!_deliveryRunning.containsKey(orderId)) { + _deliveryRunning[orderId] = false; + } + } + }); + } catch (e) { + debugPrint('[FETCH_QUEUES] Error: $e'); + } finally { + _isFetchingQueues = false; + } + } + + // Persistent client to avoid creating new connections constantly + static final http.Client _httpClient = http.Client(); + Position? _lastGeocodedPosition; + + // Fetch active deliveries from v2 API (same as deliveries page) + Future _fetchActiveDeliveries(int userId) async { + try { + // Get current date in YYYY-MM-DD format + final now = DateTime.now(); + final today = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + + // The rider's own accepted work. One Doormile route now — there is no + // dev/live split to choose between any more. See [DeliveryApi]. + final baseUrl = DeliveryApi.currentDeliveries; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + // Fetch deliveries from v2 API directly using REUSED http client + List itemsV2 = []; + try { + final response = await _httpClient.get(uri); // Use persistent client + + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + itemsV2 = data is List + ? data + : (data is Map && data['items'] is List + ? data['items'] as List + : []); + } else { + debugPrint('[HOMEPAGE] v2 API error: ${response.statusCode}'); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error fetching from v2 API: $e'); + } + // Do NOT close client here + + // Filter for ACTIVE status from v2 API + final activeOrders = itemsV2.whereType>().where(( + order, + ) { + final status = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isActive = status == 'active'; + if (isActive) { + debugPrint( + '[HOMEPAGE] ✅ Found active order from v2: ${order['orderid']}', + ); + } + return isActive; + }).toList(); + + debugPrint( + '[HOMEPAGE] Found ${activeOrders.length} active deliveries from v2 API', + ); + + if (mounted) { + setState(() { + _activeDeliveries = activeOrders; + }); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error fetching active deliveries: $e'); + } + } + + PreferredSizeWidget _buildHeader(double width) { + final height = MediaQuery.of(context).size.height; + final w = width; + + // Responsive multipliers copied from lib/homepage.dart + final titleSize = w * 0.06; // Hi, Rider + final shiftSize = w * 0.04; // Shift : 10 AM to 6 PM + final switchWidth = w * 0.32; // Status button width + final switchHeight = height * 0.045; + + return PreferredSize( + preferredSize: Size.fromHeight(height * 0.115), + child: SafeArea( + top: true, + bottom: false, + child: AppBar( + automaticallyImplyLeading: false, + backgroundColor: ColorConstants.primaryColor, + elevation: 0, + toolbarHeight: height * 0.115, // responsive toolbar height + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Hi, ${_userName.isNotEmpty ? _userName.split(' ').first : "Rider"}!", + style: TextStyle( + fontSize: titleSize, // responsive + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: height * 0.012), + Text( + (() { + final s = _shiftStart.trim(); + final e = _shiftEnd.trim(); + if (s.isEmpty || e.isEmpty) return "Shift : -"; + return "Shift : $s to $e"; + })(), + style: TextStyle( + color: Colors.white, + fontSize: shiftSize, // responsive + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + actions: [ + Padding( + padding: EdgeInsets.only(right: w * 0.03), + child: GestureDetector( + onTap: _hasActiveDelivery() ? null : _confirmOnlineOffline, + child: Container( + width: switchWidth, // responsive + height: switchHeight, // responsive + decoration: BoxDecoration( + color: isOnline ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(switchHeight * 0.9), + ), + child: Stack( + children: [ + Align( + alignment: isOnline + ? Alignment.centerLeft + : Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.04), + child: Text( + isOnline ? "ONLINE" : "OFFLINE", + style: TextStyle( + color: Colors.white, + fontSize: w * 0.038, // responsive + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + Align( + alignment: isOnline + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.all(2), + width: switchHeight * 0.78, + height: switchHeight * 0.78, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Center( + child: Image.asset( + 'assets/images/onlineoffline.png', + width: switchHeight * 0.45, + height: switchHeight * 0.45, + errorBuilder: (c, e, s) => + const SizedBox.shrink(), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + // ✅ Check if there's an active delivery (blocks all actions) + bool _hasActiveDelivery() { + return _activeDeliveries.isNotEmpty; + } + + // Previously showed a blocking dialog; now we just disable conflicting buttons in the UI. + void _showActiveDeliveryBlockMessage() {} + + // Navigate to active delivery map screen directly (same as deliveries page) + Future _navigateToActiveDelivery(Map delivery) async { + if (!mounted) return; + debugPrint( + '[HOMEPAGE] Navigate to active delivery map: ${delivery['orderid']}', + ); + + // Navigate directly to delivery map screen (same as deliveries page) + await deliveries.MyDeliveries.navigateToDeliveryMap(context, delivery); + + // Refresh after returning from map screen + if (mounted) { + await Future.delayed(const Duration(milliseconds: 500)); + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid'); + if (userId != null) { + await _fetchQueues(); + } + } + } + + String _getBulkButtonLabel() { + final selectedOrderIds = _selectedOrders.entries + .where((e) => e.value) + .map((e) => e.key) + .toList(); + + if (selectedOrderIds.isEmpty) return "Accept Orders"; + + final statuses = selectedOrderIds + .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) + .toSet(); + + if (statuses.length == 1 && statuses.first == 'ACCEPT') { + return "Accept Orders"; + } + + if (statuses.length == 1 && statuses.first == 'ACCEPTED') { + return "Move to Arrived"; + } + + if (statuses.length == 1 && statuses.first == 'ARRIVED') { + return "Move to Picked"; + } + + if (statuses.contains('ACCEPT')) { + return "Accept Orders"; + } else if (statuses.contains('ACCEPTED')) { + return "Move to Arrived"; + } else { + return "Move to Picked"; + } + } + + Future _handleBulkAction() async { + final dc = Get.put(DeliveriesController(), permanent: true); + + final selectedOrderIds = _selectedOrders.entries + .where((e) => e.value) + .map((e) => e.key) + .toList(); + + if (selectedOrderIds.isEmpty) return; + + final statuses = selectedOrderIds + .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) + .toSet(); + + String targetStatus; + if (statuses.length == 1 && statuses.first == 'ACCEPT') { + targetStatus = 'ACCEPTED'; + } else if (statuses.length == 1 && statuses.first == 'ACCEPTED') { + targetStatus = 'ARRIVED'; + } else if (statuses.length == 1 && statuses.first == 'ARRIVED') { + targetStatus = 'PICKED'; + } else { + targetStatus = 'ACCEPTED'; + } + + // ✅ PROXIMITY CHECK FOR ARRIVED STATUS + if (targetStatus == 'ARRIVED') { + debugPrint( + '[BULK] Checking proximity for ${selectedOrderIds.length} orders...', + ); + + // Quick proximity check (no loading dialog since it's fast) + final proximityResults = await _checkProximityForOrders(selectedOrderIds) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + debugPrint('[BULK] Proximity check timeout, allowing all'); + // Return all true on timeout + return Map.fromEntries( + selectedOrderIds.map((id) => MapEntry(id, true)), + ); + }, + ); + + // Find orders that are NOT near pickup + final farOrders = proximityResults.entries + .where((e) => !e.value) + .map((e) => e.key) + .toList(); + + if (farOrders.isNotEmpty) { + // Show error for all orders that are too far + if (mounted) { + final message = farOrders.length == selectedOrderIds.length + ? 'You must be within 500 meters of the pickup location(s) to mark orders as arrived. Please move closer and try again.' + : 'Some selected orders are too far from their pickup locations. Please move closer or deselect those orders.'; + + await _showProximityWarning(context, specificMessage: message); + } + return; // Don't proceed with the update + } + + debugPrint('[BULK] All orders within 500m of pickup. Proceeding...'); + } + + debugPrint( + '[BULK] Moving ${selectedOrderIds.length} orders to $targetStatus', + ); + + // TRIGGER CAMERA FOR BULK PICKED + XFile? bulkPhoto; + if (targetStatus == 'PICKED') { + try { + final ImagePicker picker = ImagePicker(); + bulkPhoto = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 50, + ); + if (bulkPhoto == null) return; // Abort if cancelled + } catch (e) { + debugPrint('[BULK] Camera error: $e'); + return; + } + } + + // Show minimal loading indicator (only for bulk operations) + showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black.withOpacity(0.3), + builder: (ctx) => const Center(child: CircularProgressIndicator()), + ); + + try { + // Upload Bulk Proof if available + String? bulkProofImageUrl; + if (bulkPhoto != null) { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = int.tryParse(prefs.getString('userid') ?? '0') ?? 0; + if (userId > 0) { + bulkProofImageUrl = await dc.uploadProofImage( + File(bulkPhoto.path), + 'picked', + userId, + 0, // 0 for bulk upload + ); + } + } catch (e) { + debugPrint('[BULK] Upload error: $e'); + } + } + + for (final orderId in selectedOrderIds) { + final order = _ordersMap[orderId]; + if (order == null) continue; + + final deliveryId = int.tryParse('${order['deliveryid'] ?? 0}') ?? 0; + final orderHeaderId = + int.tryParse('${order['orderheaderid'] ?? 0}') ?? 0; + final pickupLocationId = + int.tryParse('${order['pickuplocationid'] ?? 0}') ?? 0; + + bool success = false; + + if (targetStatus == 'ACCEPTED') { + debugPrint('[BULK] Accepting order $orderId'); + success = await dc + .updateAcceptedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + ) + .timeout(const Duration(seconds: 20), onTimeout: () => false); + } else if (targetStatus == 'ARRIVED') { + debugPrint('[BULK] Arriving order $orderId'); + + final pickupLat = _parseDouble( + order['pickuplat'] ?? order['PickupLat'] ?? 0, + ); + final pickupLng = _parseDouble( + order['pickuplon'] ?? order['PickupLon'] ?? 0, + ); + final riderLoc = await _getValidCoordinates(); + + success = await dc + .updateArrivedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + pickupLat: pickupLat.toString(), + pickupLng: pickupLng.toString(), + ridersLat: riderLoc?.$1 ?? '0', + ridersLng: riderLoc?.$2 ?? '0', + ) + .timeout(const Duration(seconds: 20), onTimeout: () => false); + } else if (targetStatus == 'PICKED') { + debugPrint('[BULK] Picking order $orderId'); + + // Get pickup coordinates and rider location for distance calculation + final pickupLat = _parseDouble( + order['pickuplat'] ?? order['PickupLat'] ?? 0, + ); + final pickupLng = _parseDouble( + order['pickuplon'] ?? order['PickupLon'] ?? 0, + ); + + final riderLoc = await _getValidCoordinates(); + final riderLatStr = riderLoc?.$1 ?? '0'; + final riderLngStr = riderLoc?.$2 ?? '0'; + + success = await dc + .updatePickedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + pickupLocationId: pickupLocationId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + proofImage: bulkProofImageUrl, + ) + .timeout(const Duration(seconds: 20), onTimeout: () => false); + + if (success) { + _hiddenOrderIds.add(orderId); + } + } + + if (!success) { + debugPrint('[BULK] Failed to update order $orderId'); + } + + // Reduced delay between requests + await Future.delayed(const Duration(milliseconds: 50)); + } + + setState(() { + _selectedOrders.clear(); + isAllSelected = false; + }); + + await _fetchQueues(); + } catch (e) { + debugPrint('[BULK] Error: $e'); + } finally { + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + + final selectedCount = _selectedOrders.values.where((s) => s).length; + + return WillPopScope( + onWillPop: () async { + try { + final prefs = await SharedPreferences.getInstance(); + final hasLive = prefs.getBool('has_live_deliveries') ?? false; + if (hasLive) { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Pending deliveries'), + content: const Text( + 'Are you sure you want to close? There are deliveries pending.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Yes'), + ), + ], + ), + ); + return confirm == true; + } + } catch (_) {} + return true; + }, + child: SafeArea( + top: true, + bottom: true, + left: true, + right: true, + child: Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: _buildHeader(MediaQuery.of(context).size.width), + body: Stack( + children: [ + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(14.0), + child: Text( + "Orders Pending", + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + Transform.translate( + offset: Offset(-10, 1), + child: Text( + "Select all", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Transform.translate( + offset: Offset(-10, 0), + child: Transform.scale( + scale: 1.3, + child: Checkbox( + value: isAllSelected, + activeColor: ColorConstants.primaryColor, + onChanged: + _orderIds.isEmpty || _hasActiveDelivery() + ? null + : (value) { + setState(() { + isAllSelected = value ?? false; + for (final orderId in _orderIds) { + _selectedOrders[orderId] = + isAllSelected; + } + }); + }, + ), + ), + ), + ], + ), + ), + ], + ), + Divider(color: Colors.grey.shade400, thickness: 1, height: 1), + + Expanded( + child: _orderIds.isEmpty + ? Transform.translate( + offset: const Offset(0, -4), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + "assets/images/Doormile Bike.png", + ), + const SizedBox(height: 16), + Text( + "No Orders at the moment", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + itemCount: _orderIds.length, + itemBuilder: (context, index) { + final orderId = _orderIds[index]; + final item = _ordersMap[orderId] ?? {}; + + final title = + (item['deliverycustomer'] ?? + 'Senthil Stores, RS Puram') + .toString(); + final address = + (item['deliveryaddress'] ?? + '23, 2nd street, Race course') + .toString(); + final tenant = (item['tenantname'] ?? '') + .toString(); + final pickupCustomer = + (item['pickupcustomer'] ?? '') + .toString() + .trim(); + final bool hasPickupCustomer = + pickupCustomer.isNotEmpty; + final String primaryStoreName = hasPickupCustomer + ? pickupCustomer + : tenant; + final String secondaryStoreName = + hasPickupCustomer ? tenant : ''; + final int quantity = + int.tryParse( + '${item['Quantity'] ?? item['quantity'] ?? 0}', + ) ?? + 0; + final isSelected = + _selectedOrders[orderId] ?? false; + + final currentStatus = _getStatusFromOrder(item); + + return Container( + key: ValueKey('order_$orderId'), + margin: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 12, + height: 12, + decoration: + const BoxDecoration( + color: Colors.orange, + shape: + BoxShape.circle, + ), + ), + Container( + width: 2, + height: 30, + color: Colors.grey.shade300, + ), + Container( + width: 12, + height: 12, + decoration: + const BoxDecoration( + color: Colors.green, + shape: + BoxShape.circle, + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: const Offset( + 0, + -5, + ), + child: Text( + title, + style: TextStyle( + fontWeight: + FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ), + const SizedBox(height: 18), + Transform.translate( + offset: const Offset( + 0, + -1, + ), + child: Text( + address, + style: TextStyle( + fontSize: 18, + color: Colors.black87, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ), + const SizedBox(height: 6), + if (quantity > 0) + Text( + 'Quantity: $quantity', + style: TextStyle( + fontSize: 19, + color: Colors + .blueGrey + .shade900, + fontFamily: + FontConstants + .fontFamily, + fontWeight: + FontWeight.bold, + ), + ), + ], + ), + ), + GestureDetector( + onTap: _hasActiveDelivery() + ? null + : () { + setState(() { + _selectedOrders[orderId] = + !isSelected; + isAllSelected = + _selectedOrders + .values + .every( + (s) => s, + ); + }); + }, + child: Container( + padding: const EdgeInsets.all( + 4, + ), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants + .primaryColor, + width: 2, + ), + ), + child: AnimatedContainer( + duration: const Duration( + milliseconds: 200, + ), + width: 20, + height: 20, + decoration: BoxDecoration( + color: isSelected + ? ColorConstants + .primaryColor + : Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + LayoutBuilder( + builder: (context, constraints) => + Row( + children: List.generate( + (constraints.maxWidth / 6) + .floor(), + (index) => const Expanded( + child: Padding( + padding: + EdgeInsets.symmetric( + horizontal: 1.5, + ), + child: Divider( + color: Colors.grey, + thickness: 1, + height: 1, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 10), + SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Image.asset( + "assets/images/shoppingbag.png", + height: 32, + width: 32, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + primaryStoreName, + style: TextStyle( + fontWeight: + FontWeight + .w600, + fontSize: 16, + color: + Colors.black, + fontFamily: + FontConstants + .fontFamily, + ), + ), + if (secondaryStoreName + .isNotEmpty) ...[ + const SizedBox( + height: 2, + ), + Text( + secondaryStoreName, + style: TextStyle( + fontSize: 16, + color: Colors + .blueGrey + .shade700, + fontFamily: + FontConstants + .fontFamily, + fontWeight: + FontWeight + .w500, + ), + ), + ], + ], + ), + Text( + "Order ID: #$orderId", + style: TextStyle( + fontSize: 18, + color: Colors.black54, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ], + ), + ), + InkWell( + onTap: () async { + // Just launch dialer, no PiP + final phone = + (item['pickupcontactno'] ?? + '') + .toString(); + final bool success = + await launchPhoneDialer( + phone.isNotEmpty + ? phone + : '9876543210', + ); + if (!success && + context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Could not launch dialer', + ), + ), + ); + } + }, + child: Image.asset( + "assets/images/phone-call .png", + height: 27, + width: 27, + errorBuilder: (c, e, s) => + const Icon( + Icons.phone, + size: 27, + color: Colors.green, + ), + ), + ), + SizedBox(width: 13), + InkWell( + onTap: () { + _showmapDetailsSheet( + context, + item, + ); + }, + child: Row( + children: [ + Image.asset( + "assets/images/map.png", + height: 26, + width: 26, + ), + const SizedBox(width: 15), + InkWell( + onTap: () { + _showProductDetailsSheet( + context, + ); + }, + child: Image.asset( + "assets/images/information-point.png", + height: 27, + width: 27, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Status buttons row + Row( + children: [ + Expanded( + child: OrderStatusRow( + key: ValueKey( + 'status_${orderId}_$currentStatus', + ), + currentStatus: currentStatus, + enabled: + selectedCount == 1 && + isSelected && + !(_statusBusy[orderId] ?? + false), + onStatusChange: + ( + newStatus, { + String? notes, + String? proofImagePath, + }) async { + debugPrint( + '[SINGLE] Order $orderId: $currentStatus -> $newStatus', + ); + + // ✅ PROXIMITY CHECK FOR ARRIVED STATUS (SINGLE ORDER) + // ✅ PROXIMITY CHECK DELEGATED TO CONTROLLER + // We removed the local check here because the controller + // already performs a robust geofence check with better error handling. + + if (_statusBusy[orderId] == + true) { + return false; + } + + if (mounted) { + setState(() { + _statusBusy[orderId] = + true; + }); + } + + final dc = Get.put( + DeliveriesController(), + permanent: true, + ); + final deliveryId = + int.tryParse( + '${item['deliveryid'] ?? 0}', + ) ?? + 0; + final orderHeaderId = + int.tryParse( + '${item['orderheaderid'] ?? 0}', + ) ?? + 0; + final pickupLocationId = + int.tryParse( + '${item['pickuplocationid'] ?? 0}', + ) ?? + 0; + + bool success = false; + + try { + if (newStatus == + 'ACCEPTED') { + debugPrint( + '[SINGLE] updateAcceptedStatus deliveryId=$deliveryId', + ); + success = await dc + .updateAcceptedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + } else if (newStatus == + 'ARRIVED') { + debugPrint( + '[SINGLE] updateArrivedStatus deliveryId=$deliveryId', + ); + + final pickupLat = + _parseDouble( + item['pickuplat'] ?? + item['PickupLat'] ?? + 0, + ); + final pickupLng = + _parseDouble( + item['pickuplon'] ?? + item['PickupLon'] ?? + 0, + ); + final riderLoc = + await _getValidCoordinates(); + + success = await dc + .updateArrivedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + pickupLat: pickupLat + .toString(), + pickupLng: pickupLng + .toString(), + ridersLat: + riderLoc?.$1 ?? + '0', + ridersLng: + riderLoc?.$2 ?? + '0', + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + } else if (newStatus == + 'PICKED') { + debugPrint( + '[SINGLE] updatePickedStatus deliveryId=$deliveryId', + ); + + final pickupLat = + _parseDouble( + item['pickuplat'] ?? + item['PickupLat'] ?? + 0, + ); + // PROOF OF DELIVERY UPLOAD + String? proofImageUrl; + if (proofImagePath != + null) { + try { + final prefs = + await SharedPreferences.getInstance(); + // Safely retrieve 'userid' which might be stored as int or String + final rawUserId = + prefs.get( + 'userid', + ); + final userId = + int.tryParse( + '${rawUserId ?? 0}', + ) ?? + 0; + + if (userId > 0) { + proofImageUrl = await dc + .uploadProofImage( + File( + proofImagePath, + ), + 'picked', + userId, + deliveryId, + ); + debugPrint( + '[SINGLE] Picked Proof URL: $proofImageUrl', + ); + } + } catch (e) { + debugPrint( + 'Error uploading proof image: $e', + ); + } + } + + final pickupLng = + _parseDouble( + item['pickuplon'] ?? + item['PickupLon'] ?? + 0, + ); + + final riderLoc = + await _getValidCoordinates(); + final riderLatStr = + riderLoc?.$1 ?? '0'; + final riderLngStr = + riderLoc?.$2 ?? '0'; + + success = await dc + .updatePickedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + pickupLocationId: + pickupLocationId, + ridersLat: + riderLatStr, + ridersLng: + riderLngStr, + pickupLat: pickupLat + .toStringAsFixed( + 6, + ), + pickupLng: pickupLng + .toStringAsFixed( + 6, + ), + proofImage: + proofImageUrl, + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + + if (success) { + // Signal Deliveries page to refresh immediately + dc.triggerRefresh(); + + _startDeliveryPosting( + item, + ); + _hiddenOrderIds.add( + orderId, + ); + } + } else if (newStatus == + 'REJECTED') { + debugPrint( + '[SINGLE] updateRejectedStatus deliveryId=$deliveryId notes=${notes ?? ''}', + ); + success = await dc + .updateRejectedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + notes: notes ?? '', + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + } + + if (success) { + if (newStatus == + 'REJECTED' || + newStatus == + 'PICKED') { + if (mounted) { + setState(() { + _hiddenOrderIds.add( + orderId, + ); + _ordersMap.remove( + orderId, + ); + _orderIds.remove( + orderId, + ); + _selectedOrders + .remove( + orderId, + ); + _statusBusy.remove( + orderId, + ); + }); + } + await _fetchQueues(); + if (mounted) { + if (newStatus == + 'REJECTED') { + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Order rejected.', + ), + ), + ); + } else if (newStatus == + 'PICKED') { + // Optional: Confirm move to next tab + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: Text('Order moved to Deliveries.'), + // duration: Duration(seconds: 1), + // ), + // ); + } + } + } else { + if (mounted) { + setState(() { + final order = + _ordersMap[orderId]; + if (order != null) { + order['orderstatus'] = + newStatus + .toLowerCase(); + } + _statusBusy[orderId] = + false; + }); + } + await _fetchQueues(); + if (mounted && + newStatus == + 'ARRIVED') { + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Arrived status updated.', + ), + ), + ); + } + } + } else { + debugPrint( + '[SINGLE] API call failed for order $orderId', + ); + if (mounted) { + setState(() { + _statusBusy[orderId] = + false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Failed to update status. Please try again.', + ), + ), + ); + } + } + } catch (e) { + debugPrint( + '[SINGLE] Error updating order $orderId: $e', + ); + if (mounted) { + setState(() { + _statusBusy[orderId] = + false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Error updating status.', + ), + ), + ); + } + } + + return success; + }, + ), + ), + ], + ), + ], + ), + ); + }, + ), + ), + + // Bulk action button + if (selectedCount > 1) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + child: ElevatedButton( + onPressed: _hasActiveDelivery() + ? null + : _handleBulkAction, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text( + _getBulkButtonLabel(), + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + // Bottom banner for active deliveries (Swiggy/Zomato style) + _activeDeliveries.isNotEmpty + ? Positioned( + left: 0, + right: 0, + bottom: 0, + child: ActiveDeliveryBanner( + activeDeliveries: _activeDeliveries, + onTap: (delivery) async { + await _navigateToActiveDelivery(delivery); + // Refresh after returning from map screen + if (mounted) { + await Future.delayed( + const Duration(milliseconds: 500), + ); + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid'); + if (userId != null) { + await _fetchQueues(); + } + } + }, + ), + ) + : const SizedBox.shrink(), + ], + ), + ), + ), + ); + } + + void _startDeliveryPosting(Map it) async { + final orderId = (it['orderid'] ?? '').toString(); + if (orderId.isEmpty) return; + + final base = { + 'logid': 0, + 'tenantid': it['tenantid'] ?? 0, + 'partnerid': it['partnerid'] ?? 0, + 'locationid': it['locationid'] ?? 0, + 'orderheaderid': it['orderheaderid'] ?? 0, + 'deliveryid': it['deliveryid'] ?? 0, + 'userid': it['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'picked', + }; + _deliveryBasePayload[orderId] = base; + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'deliverylog_${orderId}_tenantid', + (base['tenantid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_partnerid', + (base['partnerid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_locationid', + (base['locationid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_orderheaderid', + (base['orderheaderid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_deliveryid', + (base['deliveryid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_userid', + (base['userid'] ?? 0).toString(), + ); + await prefs.setString('deliverylog_${orderId}_orderid', orderId); + await prefs.setString( + 'deliverylog_${orderId}_orderstatus', + (base['orderstatus'] ?? 'picked').toString(), + ); + } catch (_) {} + + await _postDeliveryLog(orderId); + final interval = await _getLogInterval(); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers[orderId] = Timer.periodic(interval, (_) { + _postDeliveryLog(orderId); + }); + } + + Future _postDeliveryLog(String orderId) async { + try { + Map? base = _deliveryBasePayload[orderId]; + if (base == null) { + try { + final prefs = await SharedPreferences.getInstance(); + base = { + 'logid': 0, + 'tenantid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_tenantid') ?? '0', + ) ?? + 0, + 'partnerid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_partnerid') ?? '0', + ) ?? + 0, + 'locationid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_locationid') ?? '0', + ) ?? + 0, + 'orderheaderid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_orderheaderid') ?? + '0', + ) ?? + 0, + 'deliveryid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0', + ) ?? + 0, + 'userid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_userid') ?? '0', + ) ?? + 0, + 'orderid': + prefs.getString('deliverylog_${orderId}_orderid') ?? orderId, + 'orderstatus': + prefs.getString('deliverylog_${orderId}_orderstatus') ?? + 'picked', + }; + } catch (_) {} + } + if (base == null) return; + + final coords = await _getValidCoordinates(); + final now = DateTime.now(); + final logdate = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + + final payload = { + ...base, + 'logdate': logdate, + 'latitude': (coords?.$1 ?? '0'), + 'longitude': (coords?.$2 ?? '0'), + }; + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + debugPrint('[DELIVERYLOG][POST] URL: $url'); + debugPrint('[DELIVERYLOG][POST] Body: $payload'); + + await _deliveryLogProvider.createDeliveryLog(url, payload); + } catch (e) { + debugPrint('[DELIVERYLOG][POST] error: $e'); + } + } + + @override + bool get wantKeepAlive => true; +} + +// Map details bottom sheet +void _showmapDetailsSheet( + BuildContext context, + Map? item, +) async { + // ⭐ STEP 1 — Show instant loader bottom sheet + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) { + return SizedBox( + height: 180, + child: Center(child: CircularProgressIndicator()), + ); + }, + ); + + // Give time to open loader smoothly + await Future.delayed(const Duration(milliseconds: 10)); + + // ⭐ STEP 2 — Now load heavy work (your full code) + double parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + final String pickupLatStr = (item?['pickuplat'] ?? item?['PickupLat'] ?? '') + .toString(); + final String pickupLngStr = (item?['pickuplon'] ?? item?['PickupLon'] ?? '') + .toString(); + // ignore: unused_local_variable + final String dropLatStr = + (item?['droplat'] ?? item?['DropLat'] ?? item?['deliverylat'] ?? '') + .toString(); + // ignore: unused_local_variable + final String dropLngStr = + (item?['droplon'] ?? item?['DropLon'] ?? item?['deliverylong'] ?? '') + .toString(); + final String orderId = (item?['orderid'] ?? '').toString(); + final String statusStr = (item?['orderstatus'] ?? '').toString(); + final String pickupAddress = + (item?['Pickupaddress'] ?? item?['pickuplocation'] ?? '').toString(); + // ignore: unused_local_variable + final String dropAddress = + (item?['deliveryaddress'] ?? item?['deliverylocation'] ?? '').toString(); + + double pickupLat = parseD(pickupLatStr); + double pickupLng = parseD(pickupLngStr); + bool hasPickup = pickupLat != 0 && pickupLng != 0; + + double riderLat = 0.0; + double riderLng = 0.0; + bool hasRiderLocation = false; + + try { + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 8), + ); + } catch (e) { + pos = await Geolocator.getLastKnownPosition(); + } + if (pos != null) { + riderLat = pos.latitude; + riderLng = pos.longitude; + hasRiderLocation = true; + } + } catch (e) {} + + double distanceMeters = 0.0; + String distanceKm = '0.00'; + if (hasRiderLocation && hasPickup) { + distanceMeters = Geolocator.distanceBetween( + riderLat, + riderLng, + pickupLat, + pickupLng, + ); + distanceKm = (distanceMeters / 1000).toStringAsFixed(2); + } + + final bool hasAny = hasRiderLocation && hasPickup; + + final List polylines = []; + + if (hasRiderLocation && hasPickup) { + final route = await RoutingService.getRoute( + origin: LatLng(riderLat, riderLng), + destination: LatLng(pickupLat, pickupLng), + ); + if (route != null && route.points.isNotEmpty) { + polylines.add( + DoormileMapPolyline( + id: 'route', + points: route.points, + color: Colors.blueAccent, + width: 5, + ), + ); + } + } + + final List mapMarkers = []; + if (hasRiderLocation) { + mapMarkers.add( + DoormileMapMarker( + id: 'rider', + position: LatLng(riderLat, riderLng), + icon: await MapMarkerIcons.dot(Colors.blueAccent), + anchor: MarkerAnchor.center, + ), + ); + } + if (hasPickup) { + mapMarkers.add( + DoormileMapMarker( + id: 'pickup', + position: LatLng(pickupLat, pickupLng), + icon: await MapMarkerIcons.pin(Colors.red), + ), + ); + } + + // ⭐ STEP 3 — Close loader + Navigator.pop(context); + + // ⭐ STEP 4 — Open your FULL ORIGINAL bottom sheet (no UI changed) + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Container( + margin: const EdgeInsets.only(top: 50), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(25)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 15, + offset: const Offset(0, -5), + ), + ], + ), + child: Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 10, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 50, + height: 5, + margin: const EdgeInsets.only(bottom: 15), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(20), + ), + ), + + // ⭐ ALL YOUR ORIGINAL UI BELOW (unchanged) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Trip Map", + style: TextStyle( + fontSize: FontConstants.xxLarge(context), + fontWeight: FontWeight.bold, + color: Colors.grey.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 4), + Text( + orderId.isNotEmpty ? 'Order: #$orderId' : 'Order: -', + style: TextStyle(color: Colors.grey.shade700), + ), + ], + ), + ), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: const Icon( + Icons.cancel_rounded, + size: 36, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 5), + + if (hasAny) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Transform.translate( + offset: Offset(0, -18), + child: const Icon( + Icons.location_on, + color: Colors.red, + size: 22, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + hasPickup + ? "Pickup: ${pickupAddress.isNotEmpty ? pickupAddress : 'Your current location'}" + : 'Pickup: Not available', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 5), + + if (distanceKm != '0.00') + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + children: [ + const Icon( + Icons.navigation, + color: Colors.blue, + size: 22, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Distance to Pickup: $distanceKm km", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + color: Colors.blue.shade700, + ), + ), + ), + ], + ), + ), + if (distanceKm != '0.00') const SizedBox(height: 10), + + ClipRRect( + borderRadius: BorderRadius.circular(15), + child: Container( + height: 280, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade200), + ), + child: DoormileMap( + initialCameraPosition: CameraPosition( + target: hasRiderLocation && hasPickup + ? LatLng( + (riderLat + pickupLat) / 2, + (riderLng + pickupLng) / 2, + ) + : hasPickup + ? LatLng(pickupLat, pickupLng) + : hasRiderLocation + ? LatLng(riderLat, riderLng) + : const LatLng(0, 0), + zoom: hasRiderLocation && hasPickup ? 13 : 15, + ), + markers: mapMarkers, + polylines: polylines, + onMapReady: (controller) async { + if (hasRiderLocation && hasPickup) { + await controller.fitBounds( + LatLngBounds( + southwest: LatLng( + riderLat < pickupLat ? riderLat : pickupLat, + riderLng < pickupLng ? riderLng : pickupLng, + ), + northeast: LatLng( + riderLat > pickupLat ? riderLat : pickupLat, + riderLng > pickupLng ? riderLng : pickupLng, + ), + ), + padding: 80, + ); + } + }, + ), + ), + ), + const SizedBox(height: 8), + + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Order Details", + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Order ID:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + orderId.isNotEmpty ? "#$orderId" : '-', + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Status:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + statusStr.isNotEmpty ? statusStr : '-', + style: TextStyle( + color: Colors.orange.shade700, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Distance:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + "$distanceKm km", + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Quantity:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + "${item?['Quantity'] ?? item?['quantity'] ?? '-'}", + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); +} + +// Product details bottom sheet +void _showProductDetailsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Product Details", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + IconButton( + icon: const Icon(Icons.cancel, color: Colors.red, size: 32), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 10), + + Text( + "No product details yet", + style: TextStyle( + fontSize: 18, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/xpress/views/Dashboard/home/homepage_banner.dart b/lib/xpress/views/Dashboard/home/homepage_banner.dart new file mode 100644 index 0000000..3ebad0a --- /dev/null +++ b/lib/xpress/views/Dashboard/home/homepage_banner.dart @@ -0,0 +1,145 @@ +// Active Delivery Banner Widget for Home Page +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; + +class ActiveDeliveryBanner extends StatelessWidget { + final List> activeDeliveries; + final Function(Map) onTap; + + const ActiveDeliveryBanner({ + super.key, + required this.activeDeliveries, + required this.onTap, + }); + + String _getDeliveryAddress(Map delivery) { + final address = + delivery['deliveryaddress'] ?? + delivery['DeliveryAddress'] ?? + delivery['address'] ?? + ''; + if (address.toString().length > 40) { + return '${address.toString().substring(0, 40)}...'; + } + return address.toString(); + } + + @override + Widget build(BuildContext context) { + if (activeDeliveries.isEmpty) { + return const SizedBox.shrink(); + } + + // Show first active delivery (or show count if multiple) + final delivery = activeDeliveries.first; + final count = activeDeliveries.length; + + return Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onTap(delivery), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Active indicator icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.two_wheeler, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + // Delivery info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + count > 1 + ? '$count Active Deliveries' + : 'Active Delivery', + style: TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + if (count > 1) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + _getDeliveryAddress(delivery), + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 17, + fontFamily: FontConstants.fontFamily, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Arrow icon + const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/orders/orderstaus_button.dart b/lib/xpress/views/Dashboard/orders/orderstaus_button.dart new file mode 100644 index 0000000..7a6e163 --- /dev/null +++ b/lib/xpress/views/Dashboard/orders/orderstaus_button.dart @@ -0,0 +1,636 @@ +import 'package:flutter/material.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'dart:async'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:slide_to_submit_button/slide_to_submit_button.dart'; +import 'package:image_picker/image_picker.dart'; + +/// ------------------------------ +/// MAIN WIDGET WITH TWO BUTTONS +/// ------------------------------ +class OrderStatusRow extends StatefulWidget { + final String currentStatus; + final Future Function( + String newStatus, { + String? notes, + String? proofImagePath, + }) + onStatusChange; + final bool enabled; + + const OrderStatusRow({ + super.key, + required this.currentStatus, + required this.onStatusChange, + this.enabled = true, + }); + + @override + State createState() => _OrderStatusRowState(); +} + +class _OrderStatusRowState extends State { + bool _isProcessing = false; + String? _overrideStatus; + + @override + void didUpdateWidget(covariant OrderStatusRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentStatus != widget.currentStatus && + mounted && + _overrideStatus != null && + widget.currentStatus == _overrideStatus) { + setState(() { + _overrideStatus = null; + }); + } else if (oldWidget.currentStatus != widget.currentStatus && + _overrideStatus != null) { + _overrideStatus = null; + } + } + + String get _effectiveStatus => _overrideStatus ?? widget.currentStatus; + + Future _onStatusChanged( + String newStatus, { + String? notes, + String? proofImagePath, + }) async { + debugPrint( + '[OSR] _onStatusChanged: $newStatus, proofImagePath: $proofImagePath', + ); + if (_isProcessing) return false; + + // Optimistic UI: flip status immediately to avoid visible lag. + setState(() { + _isProcessing = true; + _overrideStatus = newStatus; + }); + + bool success = false; + bool timedOut = false; + try { + // Hard cap wait time to keep UI from spinning indefinitely. + success = await widget + .onStatusChange( + newStatus, + notes: notes, + proofImagePath: proofImagePath, + ) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + timedOut = true; + // Assume success on timeout to avoid UI rollback; data will + // refresh from server on next fetch. + return true; + }, + ); + } catch (_) { + success = false; + } finally { + if (!mounted) return success; + setState(() { + _isProcessing = false; + // If API failed, revert the optimistic status. + // If timed out, keep the optimistic status (server likely completed). + _overrideStatus = (success || timedOut) ? newStatus : null; + }); + } + return success; + } + + // ------------------------------ + // REJECT SHEET + // ------------------------------ + void _showRejectSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + String selectedReason = ""; + bool isLoading = false; + final List reasons = [ + "Customer not reachable", + "Wrong address", + "Out of delivery area", + "Other reason", + ]; + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Reject Order", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.red), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 10), + for (String reason in reasons) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GestureDetector( + onTap: () => setModalState(() { + selectedReason = reason; + }), + child: Container( + height: 55, + width: double.infinity, + decoration: BoxDecoration( + color: selectedReason == reason + ? Colors.red + : Colors.grey[300], + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + reason, + style: TextStyle( + color: selectedReason == reason + ? Colors.white + : Colors.black, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + fontSize: 16, + ), + ), + ), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: selectedReason.isEmpty || isLoading + ? null + : () async { + setModalState(() => isLoading = true); + final success = await _onStatusChanged( + "REJECTED", + notes: selectedReason, + ); + if (context.mounted) { + Navigator.pop(context, success); + } + }, + child: isLoading + ? const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ) + : const Text( + "Reject Order", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + // ------------------------------ + // CANCEL SHEET + // ------------------------------ + void _showCancelSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + bool confirmCancel = false; + bool isLoading = false; + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Cancel this order?", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + const Text( + "Once cancelled, this order will return to pending state.", + textAlign: TextAlign.center, + ), + const SizedBox(height: 25), + CheckboxListTile( + value: confirmCancel, + onChanged: (value) => + setModalState(() => confirmCancel = value ?? false), + title: const Text("I confirm to cancel this order"), + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: !confirmCancel + ? null + : () async { + setModalState(() => isLoading = true); + await Future.delayed(const Duration(seconds: 1)); + if (context.mounted) Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Order Cancelled"), + ), + ); + }, + child: isLoading + ? const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ) + : const Text( + "Confirm Cancel", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final String currentStatus = _effectiveStatus; + final bool showCancel = + currentStatus == "ARRIVED" || currentStatus == "PICKED"; + final String leftText = showCancel ? "CANCEL" : "REJECT"; + + return Stack( + children: [ + Row( + children: [ + // LEFT BUTTON (Reject / Cancel) + Expanded( + child: InkWell( + onTap: () { + if (showCancel) { + _showCancelSheet(context); + } else { + _showRejectSheet(context); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: const BoxDecoration( + color: Colors.red, // + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(12), + ), + ), + alignment: Alignment.center, + child: Text( + leftText, + style: TextStyle( + fontSize: 19, + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + + // RIGHT BUTTON (ACCEPT / ARRIVED / PICKED) + OrderStatusButton( + key: ValueKey(currentStatus), + currentStatus: currentStatus, + onStatusChange: (status, {proofImagePath}) => + _onStatusChanged(status, proofImagePath: proofImagePath), + enabled: widget.enabled && !_isProcessing, + ), + ], + ), + if (_isProcessing) + Positioned.fill( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.12), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: const Center( + child: SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// ------------------------------ +/// STATUS BUTTON LOGIC (NO DELIVERY) +/// ------------------------------ +class OrderStatusButton extends StatefulWidget { + final String currentStatus; + final Future Function(String newStatus, {String? proofImagePath}) + onStatusChange; + final bool enabled; + + const OrderStatusButton({ + super.key, + required this.currentStatus, + required this.onStatusChange, + this.enabled = true, + }); + + @override + State createState() => _OrderStatusButtonState(); +} + +class _OrderStatusButtonState extends State { + String get _buttonText => widget.currentStatus; + + Color _getButtonColor() { + switch (_buttonText) { + case "ARRIVED": + return Colors.orange; + case "PICKED": + return ColorConstants.primaryColor; + default: + return Colors.green; + } + } + + void _showStatusSheet() { + if (_buttonText == "PICKED") return; // final stage now + + bool isConfirmLoading = false; + + // Determine next status (single step) + String? nextStatus; + if (_buttonText == "ACCEPT") { + nextStatus = "ACCEPTED"; + } else if (_buttonText == "ACCEPTED") { + nextStatus = "ARRIVED"; + } else if (_buttonText == "ARRIVED") { + nextStatus = "PICKED"; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + // If for some reason we don't have a valid next status, show nothing + if (nextStatus == null) { + return const SizedBox.shrink(); + } + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Move Your Order to", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + Transform.translate( + offset: const Offset(0, -5), + child: IconButton( + icon: const Icon( + Icons.cancel, + color: Colors.red, + size: 36, + ), + onPressed: () => Navigator.pop(context), + ), + ), + ], + ), + const SizedBox(height: 20), + + // Slider to confirm moving to next status wrapped with SafeArea + SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: SizedBox( + width: double.infinity, + child: SlideToSubmit.custom( + height: 55, + sliderWidth: 40, + padding: const EdgeInsets.all(8), + backgroundDecoration: BoxDecoration( + color: _getButtonColor().withOpacity(0.5), + borderRadius: BorderRadius.circular(40), + ), + foregroundDecoration: BoxDecoration( + color: _getButtonColor(), + borderRadius: BorderRadius.circular(999), + ), + slider: Center( + child: ClipOval( + child: Container( + height: 40, + width: 40, + color: Colors.white, + padding: const EdgeInsets.all(8), + child: const Icon( + Icons.arrow_forward_ios, + size: 24, + color: Colors.black, + ), + ), + ), + ), + hint: Align( + alignment: Alignment.center, + child: Text( + 'Slide to mark $nextStatus', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: const Color.fromARGB(255, 15, 14, 14), + ), + ), + ), + onSubmit: (controller) async { + if (isConfirmLoading) return; + + setModalState(() => isConfirmLoading = true); + + // Close sheet after slide completes + if (context.mounted) { + Navigator.pop(context); + } + + // Trigger status change callback + WidgetsBinding.instance.addPostFrameCallback(( + _, + ) async { + if (nextStatus == "PICKED") { + debugPrint( + '[OSB] Status is PICKED, launching camera...', + ); + // Trigger Camera + final ImagePicker picker = ImagePicker(); + final XFile? photo = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 50, // Optimize size + ); + + if (photo != null) { + debugPrint( + '[OSB] Photo taken: ${photo.path}', + ); + // Process with image + await widget.onStatusChange( + nextStatus!, + proofImagePath: photo.path, + ); + } else { + debugPrint( + '[OSB] Camera cancelled or photo null', + ); + } + } else { + debugPrint( + '[OSB] Status NOT PICKED (is $nextStatus), normal flow', + ); + // Normal flow + await widget.onStatusChange(nextStatus!); + } + + try { + controller.reset(); + } catch (_) {} + }); + }, + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + onTap: widget.enabled ? _showStatusSheet : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: widget.enabled ? _getButtonColor() : Colors.grey, + borderRadius: const BorderRadius.only( + bottomRight: Radius.circular(12), + ), + ), + alignment: Alignment.center, + child: Text( + _buttonText, + style: TextStyle( + fontSize: 19, + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/Profilepage.dart b/lib/xpress/views/Dashboard/profile/Profilepage.dart new file mode 100644 index 0000000..99a8416 --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/Profilepage.dart @@ -0,0 +1,464 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; // ⭐ REQUIRED +import 'package:miler/xpress/views/Dashboard/profile/informations/help_center.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/profile.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/saved_address.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/faq.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/notifications_page.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/support_ticket.dart'; + +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/onboardscreens/Sign_in.dart'; +import 'package:miler/xpress/controllers/profile_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/order_alert_sound.dart'; +import 'package:miler/xpress/controllers/rewards_controller.dart'; +import 'package:miler/xpress/views/Dashboard/profile/rewards_card.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/rider_rewards_page.dart'; +import 'package:miler/xpress/utils/mqtt_service.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({super.key}); + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + late final ProfileController _profileController = + Get.isRegistered() + ? Get.find() + : Get.put(ProfileController(), permanent: true); + + final RewardsController _rewardsController = Get.put(RewardsController()); + + String _name = ''; + String _email = ''; + String _contact = ''; + + @override + void initState() { + super.initState(); + _loadProfilePrefs(); + ever(_profileController.userName, (_) => _assignFromController()); + ever(_profileController.userEmail, (_) => _assignFromController()); + ever(_profileController.userContact, (_) => _assignFromController()); + ever(_profileController.userAddress, (_) => _assignFromController()); + _profileController.loadFromPrefs(); + } + + Future _loadProfilePrefs() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _name = prefs.getString('user_name') ?? ''; + _email = prefs.getString('user_email') ?? ''; + _contact = prefs.getString('contactno') ?? ''; + _contact = prefs.getString('contactno') ?? ''; + }); + + final userId = prefs.getInt('userid') ?? 0; + if (userId > 0) { + _rewardsController.fetchBonusSummary(userId); + } + } + + void _assignFromController() { + setState(() { + if (_profileController.userName.value.trim().isNotEmpty) { + _name = _profileController.userName.value.trim(); + } + if (_profileController.userEmail.value.trim().isNotEmpty) { + _email = _profileController.userEmail.value.trim(); + } + if (_profileController.userContact.value.trim().isNotEmpty) { + _contact = _profileController.userContact.value.trim(); + } + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + backgroundColor: Colors.grey.shade200, + + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + toolbarHeight: 70.h, // ⭐ responsive + title: Padding( + padding: EdgeInsets.only(top: 12.h), + child: Text( + "PROFILE", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(1.h), + child: Divider(height: 1.h, color: Colors.grey), + ), + ), + + body: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Padding( + padding: EdgeInsets.only(bottom: 40.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 20.h), + + /// ⭐ PROFILE CARD RESPONSIVE + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: ColorConstants.primaryColor, + borderRadius: BorderRadius.circular(20.r), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.person, + color: Colors.white, + size: 22.sp, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + _name.isNotEmpty ? _name : "—", + style: TextStyle( + color: Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + SizedBox(height: 12.h), + + Row( + children: [ + Icon( + Icons.phone, + color: Colors.white, + size: 20.sp, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + _contact.isNotEmpty ? _contact : "—", + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + ), + ), + ), + ], + ), + SizedBox(height: 12.h), + + Row( + children: [ + Icon( + Icons.email, + color: Colors.white, + size: 20.sp, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + _email.isNotEmpty ? _email : "—", + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + + /// ⭐ Circle Avatar Responsive + Obx(() { + final path = _profileController.imagePath.value; + final hasImage = + path.isNotEmpty && File(path).existsSync(); + + return CircleAvatar( + radius: 40.r, + backgroundColor: Colors.grey.shade300, + backgroundImage: hasImage + ? FileImage(File(path)) + : null, + child: !hasImage + ? Icon( + Icons.person, + color: Colors.white, + size: 40.sp, + ) + : null, + ); + }), + ], + ), + ), + + SizedBox(height: 20.h), + + // ⭐ REWARDS SECTION + RewardsCard(controller: _rewardsController), + + SizedBox(height: 20.h), + + _buildHeader("Your Information"), + _buildBox([ + _buildInfoTile(Icons.person, "Profile"), + Divider(), + _buildInfoTile(Icons.location_on, "Saved Address"), + Divider(), + _buildInfoTile(Icons.card_giftcard, "Rewards"), + Divider(), + _buildInfoTile(Icons.notifications, "Notification"), + ]), + + SizedBox(height: 20.h), + + _buildHeader("Support"), + _buildBox([ + _buildInfoTile(Icons.support_agent, "Help Centre"), + Divider(), + _buildInfoTile(Icons.local_activity, "Support tickets"), + ]), + + SizedBox(height: 20.h), + + _buildHeader("Other Information"), + _buildBox([ + _buildInfoTile(Icons.translate, "Faq"), + Divider(), + // _buildInfoTile(Icons.sticky_note_2, "Terms & Conditions"), + // Divider(), + _buildInfoTile( + Icons.notifications_active, + "Order alert sound", + ), + ]), + + SizedBox(height: 55.h), + + /// ⭐ Logout Button Responsive + SizedBox( + height: 55.h, + width: double.infinity, + child: OutlinedButton( + onPressed: () => _showLogoutDialog(context), + style: OutlinedButton.styleFrom( + backgroundColor: ColorConstants.secondaryColor, + side: BorderSide(color: Colors.black, width: 0.2.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + child: Text( + "Logout", + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + + SizedBox(height: 15.h), + + Center( + child: Text( + "App Version 1.2.19", + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildHeader(String title) { + return Padding( + padding: EdgeInsets.all(8.r), + child: Text( + title, + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ); + } + + Widget _buildBox(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + ), + child: Column(children: children), + ); + } + + Widget _buildInfoTile(IconData icon, String title) { + return ListTile( + leading: Icon(icon, size: 26.sp), + title: Text( + title, + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + trailing: Icon(Icons.arrow_forward_ios, size: 22.sp), + onTap: () => _handleNavigation(title), + ); + } + + void _handleNavigation(String title) { + switch (title) { + case "Profile": + Get.to(() => Profile()); + break; + case "Saved Address": + Get.to(() => const SavedAddressPage()); + break; + case "Notification": + Get.to(() => const NotificationsPage()); + break; + case "Help Centre": + Get.to(() => HelpCenter()); + break; + case "Support tickets": + Get.to(() => SupportTicket()); + break; + case "Rewards": + Get.to(() => const RiderRewardsPage()); + break; + case "Faq": + Get.to(() => const FaqPage()); + break; + // case "Terms & Conditions": + // Get.to(() => const TermsCondition()); + // break; + case "Order alert sound": + Get.to(() => const OrderAlertSoundPage()); + break; + default: + debugPrint("Tapped on $title — no page linked yet."); + } + } +} + +void _showLogoutDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return Center( + child: FittedBox( + child: AlertDialog( + backgroundColor: ColorConstants.secondaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20.r), + ), + title: Text( + "Logout", + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + content: Text( + "Are you sure you want to logout?", + style: TextStyle( + fontSize: 19.sp, + fontFamily: FontConstants.fontFamily, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + "No", + style: TextStyle( + fontSize: 19.sp, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color.fromARGB(255, 153, 121, 167), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + final userId = + prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + if (userId != 0) { + await prefs.remove('skipped_orders_cache_$userId'); + } + DoormileMqttService().disconnect(); + prefs.setBool('logged_out', true); + Get.offAll(() => const SignIn()); + }, + child: Text( + "Yes", + style: TextStyle( + fontSize: 19.sp, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/xpress/views/Dashboard/profile/informations/faq.dart b/lib/xpress/views/Dashboard/profile/informations/faq.dart new file mode 100644 index 0000000..4b194e3 --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/faq.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:get/get.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class FaqController extends GetxController { + WebViewController? webViewController; + var isLoading = true.obs; + + @override + void onInit() { + super.onInit(); + initializeWebView(); + } + + void initializeWebView() { + webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(const Color(0x00000000)) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (url) { + isLoading.value = true; + print('Started loading: $url'); + }, + onPageFinished: (url) { + isLoading.value = false; + print('Finished loading: $url'); + }, + onWebResourceError: (error) { + isLoading.value = false; + print('WebView error: ${error.description}'); + }, + ), + ); + loadFaqUrl(); + } + + Future loadFaqUrl() async { + if (webViewController != null) { + try { + await webViewController!.loadRequest( + Uri.parse('https://doormile.in/faq'), + ); + } catch (e) { + print('Error loading URL: $e'); + } + } + } +} + +class FaqPage extends StatelessWidget { + const FaqPage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(FaqController()); + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + ), // :small_blue_diamond: white back arrow + onPressed: () { + Navigator.pop(context); // goes back to previous screen + }, + ), + title: const Text( + 'FAQ', + style: TextStyle( + fontSize: 26, // :small_blue_diamond: larger font size + color: Colors.white, // :small_blue_diamond: white text + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: Obx(() { + final wvc = controller.webViewController; + return Stack( + children: [ + if (wvc != null) WebViewWidget(controller: wvc), + if (controller.isLoading.value) + const LinearProgressIndicator(minHeight: 2), + ], + ); + }), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/informations/help_center.dart b/lib/xpress/views/Dashboard/profile/informations/help_center.dart new file mode 100644 index 0000000..fc0c8ae --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/help_center.dart @@ -0,0 +1,456 @@ +import 'package:flutter/material.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; + +class HelpCenter extends StatelessWidget { + const HelpCenter({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () { + Navigator.pop(context); + }, + ), + title: const Text( + 'Help Center', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header text + Text( + "We're here to help you with anything and \neverything on Doormile Xpress", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + Text( + "We make sure your delivery experience is smooth and clear. Whether you're on your first trip or your hundredth, we've got your back. Browse through frequently asked questions or reach out directly if you need further help.", + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade700, + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 16), + + TextField( + decoration: InputDecoration( + hintText: 'Search help', + prefixIcon: const Icon(Icons.search), + contentPadding: const EdgeInsets.symmetric( + vertical: 0, + horizontal: 12, + ), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: ColorConstants.primaryColor, + width: 1.5, + ), + ), + ), + ), + + const SizedBox(height: 15), + Text( + 'FAQ', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + Divider(), + + _FaqTile( + title: 'What is Doormile Xpress?', + initiallyExpanded: true, + child: const Text( + 'Doormile Xpress is a delivery app for riders who complete local deliveries for nearby stores and markets. It helps riders accept tasks, manage pickup and drop points, and update deliveries in real time.', + style: TextStyle( + fontSize: 16, + color: Colors.black87, + height: 1.4, + ), + ), + ), + _FaqTile( + title: 'How do I accept a delivery task?', + child: const Text( + 'You can accept tasks from the Home screen when a new order appears. Tap on the order to view details and then press Accept.', + style: TextStyle( + fontSize: 16, + color: Colors.black87, + height: 1.4, + ), + ), + ), + _FaqTile( + title: 'How do I update the delivery status?', + child: const Text( + 'Open the active task and use the status buttons to mark Pickup, On the way, and Delivered. Ensure accurate updates for better tracking.', + style: TextStyle( + fontSize: 16, + color: Colors.black87, + height: 1.4, + ), + ), + ), + _FaqTile( + title: 'Can I view my past deliveries?', + child: const Text( + 'Yes. Go to the History section from your dashboard to see completed deliveries and earnings.', + style: TextStyle( + fontSize: 16, + color: Colors.black87, + height: 1.4, + ), + ), + ), + _FaqTile( + title: 'What if I face an issue during delivery?', + child: const Text( + 'Use the Help Center to report an issue or contact support. Provide order details and a short description of the problem.', + style: TextStyle( + fontSize: 16, + color: Colors.black87, + height: 1.4, + ), + ), + ), + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Still stuck? Help is a mail away!", + style: TextStyle( + fontSize: 18, + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + ), + ), + ], + ), + ], + ), + ), + bottomNavigationBar: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + height: 55, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: ColorConstants.primaryColor, + side: BorderSide( + color: ColorConstants.primaryColor, + width: 1.2, + ), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const HelpCenterMessage()), + ); + }, + child: Text( + 'Send a message', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _FaqTile extends StatelessWidget { + final String title; + final Widget child; + final bool initiallyExpanded; + + const _FaqTile({ + required this.title, + required this.child, + this.initiallyExpanded = false, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: initiallyExpanded, + tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + collapsedShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + title: Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + fontFamily: FontConstants.fontFamily, + ), + ), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: child, + ), + ], + ), + ), + ); + } +} + +// -------------------------------message page------------------------------------ +class HelpCenterMessage extends StatefulWidget { + const HelpCenterMessage({super.key}); + + @override + State createState() => _HelpCenterMessageState(); +} + +class _HelpCenterMessageState extends State { + final _subjectController = TextEditingController(); + final _messageController = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _subjectController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 80, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Help Centre', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Heading + Text( + 'Send Us a Message', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + Text( + "Not finding what you're looking for in the FAQs? Don't worry—we're here to help!", + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade700, + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 20), + + // Subject label + Text( + 'Subject', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _subjectController, + decoration: InputDecoration( + hintText: 'Type Something', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: ColorConstants.primaryColor, + width: 1.5, + ), + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Please enter a subject' + : null, + ), + + const SizedBox(height: 18), + + // Message label + Text( + 'Your Message', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _messageController, + minLines: 5, + maxLines: 8, + decoration: InputDecoration( + hintText: 'Type Something', + filled: true, + fillColor: Colors.white, + alignLabelWithHint: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: ColorConstants.primaryColor, + width: 1.5, + ), + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Please enter your message' + : null, + ), + SizedBox(height: 30), + SizedBox( + height: 55, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message sent')), + ); + Navigator.pop(context); + } + }, + child: Text( + 'Send a message', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/informations/notifications_page.dart b/lib/xpress/views/Dashboard/profile/informations/notifications_page.dart new file mode 100644 index 0000000..5d3ec7e --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/notifications_page.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; + +class NotificationsPage extends StatefulWidget { + const NotificationsPage({super.key}); + + @override + State createState() => _NotificationsPageState(); +} + +class _NotificationsPageState extends State { + List> _items = const []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('notifications_log'); + List> parsed = []; + if (raw != null && raw.isNotEmpty) { + try { + final list = jsonDecode(raw) as List; + parsed = list + .map((e) => (e as Map).map((k, v) => MapEntry(k.toString(), v))) + .toList(); + } catch (_) {} + } + if (!mounted) return; + setState(() { + _items = parsed; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, // increases AppBar height + elevation: 4, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + ), // white back arrow + onPressed: () { + Navigator.pop(context); + }, + ), + title: Text( + 'Notifications', + style: const TextStyle( + fontSize: 26, // larger font size + color: Colors.white, // white text + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ).copyWith(fontFamily: FontConstants.fontFamily), // keep your font + ), + actions: [ + IconButton( + icon: const Icon( + Icons.delete_sweep, + color: Colors.white, + ), // white icon + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('notifications_log'); + if (!mounted) return; + setState(() => _items = const []); + // ignore: use_build_context_synchronously + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Notifications cleared')), + ); + }, + ), + ], + ), + + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty + ? Center( + child: Text( + 'No notifications yet', + style: TextStyle( + fontSize: 20, + fontFamily: FontConstants.fontFamily, + ), + ), + ) + : RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final it = _items[index]; + final title = (it['title'] ?? 'Doormile').toString(); + final body = (it['body'] ?? '').toString(); + final time = (it['time'] ?? '').toString(); + final imageUrl = (it['imageUrl'] ?? '').toString(); + final imagePath = (it['imagePath'] ?? '').toString(); + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 1.5, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.notifications_active, + color: Colors.purple, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + if (time.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + time, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ), + ], + ), + ), + ], + ), + if (body.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + body, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 14, + ), + ), + ), + if (imagePath.isNotEmpty || imageUrl.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: imagePath.isNotEmpty + ? Image.file( + File(imagePath), + height: 170, + width: double.infinity, + fit: BoxFit.cover, + ) + : Image.network( + imageUrl, + height: 170, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/informations/order_alert_sound.dart b/lib/xpress/views/Dashboard/profile/informations/order_alert_sound.dart new file mode 100644 index 0000000..61703a6 --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/order_alert_sound.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; + +class OrderAlertSoundPage extends StatefulWidget { + const OrderAlertSoundPage({super.key}); + + @override + State createState() => _OrderAlertSoundPageState(); +} + +class _OrderAlertSoundPageState extends State { + static const String _prefsKey = 'order_alert_sound'; + static const String _defaultSound = 'assets/audio/alert-1.mp3'; + + final AudioPlayer _player = AudioPlayer(); + String _selected = _defaultSound; + bool _loading = true; + + // Available sounds from assets/audio/ folder + final List<_SoundOption> _options = const [ + _SoundOption( + label: 'Alert 1 (Default)', + assetPath: 'assets/audio/alert-1.mp3', + ), + _SoundOption(label: 'Alert 2', assetPath: 'assets/audio/alert-2.mp3'), + _SoundOption(label: 'Alert 3', assetPath: 'assets/audio/alert-3.mp3'), + _SoundOption(label: 'Alert 4', assetPath: 'assets/audio/alert-4.mp3'), + _SoundOption(label: 'Alert 5', assetPath: 'assets/audio/alert-5.mp3'), + _SoundOption(label: 'Alert 6', assetPath: 'assets/audio/alert-6.mp3'), + _SoundOption(label: 'Alert 7', assetPath: 'assets/audio/alert-7.mp3'), + _SoundOption(label: 'Alert 8', assetPath: 'assets/audio/alert-8.mp3'), + _SoundOption(label: 'Alert 9', assetPath: 'assets/audio/alert-9.mp3'), + _SoundOption(label: 'Alert 10', assetPath: 'assets/audio/alert-10.mp3'), + ]; + + @override + void initState() { + super.initState(); + _loadSelection(); + } + + Future _loadSelection() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + setState(() { + _selected = (saved != null && saved.isNotEmpty) ? saved : _defaultSound; + _loading = false; + }); + } + + Future _saveSelection(BuildContext context, String assetPath) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, assetPath); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Order alert sound updated'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 2), + ), + ); + } + + Future _preview(BuildContext context, String assetPath) async { + try { + await _player.stop(); + await _player.play(AssetSource(assetPath.replaceFirst('assets/', ''))); + // Note: AssetSource expects relative to assets/ root; hence replaceFirst + } catch (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Preview unavailable for: $assetPath'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + duration: const Duration(seconds: 2), + ), + ); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, // :small_blue_diamond: increases app bar height + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + ), // :small_blue_diamond: white back arrow + onPressed: () { + Navigator.pop(context); // goes back to previous screen + }, + ), + title: const Text( + 'Orders alert Sound', + style: TextStyle( + fontSize: 26, // :small_blue_diamond: larger font size + color: Colors.white, // :small_blue_diamond: white text + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + color: Colors.grey.shade200, + child: Row( + children: [ + const Icon(Icons.volume_up, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Current: ${_options.firstWhereOrNull((o) => o.assetPath == _selected)?.label ?? 'Unknown'}', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Text( + 'Tap a sound to select', + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + itemCount: _options.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final opt = _options[index]; + final isSelected = _selected == opt.assetPath; + return ListTile( + title: Text( + opt.label, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + ), + ), + leading: Radio( + value: opt.assetPath, + groupValue: _selected, + onChanged: (value) { + if (value == null) return; + setState(() => _selected = value); + _saveSelection(context, value); + }, + ), + trailing: IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => _preview(context, opt.assetPath), + ), + onTap: () { + setState(() => _selected = opt.assetPath); + _saveSelection(context, opt.assetPath); + }, + subtitle: isSelected + ? const Text( + 'Selected', + style: TextStyle(fontSize: 12), + ) + : null, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _SoundOption { + final String label; + final String assetPath; + const _SoundOption({required this.label, required this.assetPath}); +} diff --git a/lib/xpress/views/Dashboard/profile/informations/profile.dart b/lib/xpress/views/Dashboard/profile/informations/profile.dart new file mode 100644 index 0000000..98398d9 --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/profile.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:miler/xpress/controllers/profile_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; + +class Profile extends StatefulWidget { + const Profile({super.key}); + + @override + State createState() => _ProfileState(); +} + +class _ProfileState extends State { + String _name = ''; + String _email = ''; + String _contact = ''; + String _address = ''; + final List _workers = []; + late final ProfileController _profileController = + Get.isRegistered() + ? Get.find() + : Get.put(ProfileController(), permanent: true); + + @override + void initState() { + super.initState(); + _loadProfile(); + // Keep in sync with controller + _workers.addAll([ + ever(_profileController.userName, (_) => _assignFromController()), + ever(_profileController.userEmail, (_) => _assignFromController()), + ever(_profileController.userContact, (_) => _assignFromController()), + ever(_profileController.userAddress, (_) => _assignFromController()), + ]); + _profileController.loadFromPrefs(); + } + + @override + void dispose() { + for (final worker in _workers) { + worker.dispose(); + } + super.dispose(); + } + + Future _loadProfile() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _name = prefs.getString('user_name') ?? ''; + _email = prefs.getString('user_email') ?? ''; + _contact = prefs.getString('contactno') ?? ''; + _address = prefs.getString('user_address') ?? ''; + }); + debugPrint( + '[PROFILE_DETAILS] Loaded - Name: "$_name", Email: "$_email", Contact: "$_contact"', + ); + } + + void _assignFromController() { + setState(() { + if (_profileController.userName.value.trim().isNotEmpty) { + _name = _profileController.userName.value.trim(); + } + if (_profileController.userEmail.value.trim().isNotEmpty) { + _email = _profileController.userEmail.value.trim(); + } + if (_profileController.userContact.value.trim().isNotEmpty) { + _contact = _profileController.userContact.value.trim(); + } + if (_profileController.userAddress.value.trim().isNotEmpty) { + _address = _profileController.userAddress.value.trim(); + } + }); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final width = size.width; + // ignore: unused_local_variable + final height = size.height; + return Scaffold( + backgroundColor: Colors.grey.shade200, + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.all(width * 0.04), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Profile image + Center( + child: Stack( + children: [ + // White border circle + Container( + padding: const EdgeInsets.all(4), // border thickness + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, // white border + ), + child: CircleAvatar( + radius: 60, + backgroundColor: Colors.grey.shade400, + child: const Icon( + Icons.person, + size: 60, + color: Colors.white, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 40), + + // Name + _buildLabel("Enter name", required: true), + _buildTextField( + hintText: _name.isNotEmpty ? _name : "EX: Vijayan", + value: _name.isNotEmpty ? _name : null, + readOnly: true, + disabled: true, + ), + + const SizedBox(height: 16), + + // Contact No + _buildLabel("Contact no", required: true), + _buildTextField( + hintText: _contact.isNotEmpty + ? "+91 $_contact" + : "EX: +91 8838304677", + value: _contact.isNotEmpty ? "+91 $_contact" : null, + keyboardType: TextInputType.phone, + readOnly: true, + disabled: true, + ), + + const SizedBox(height: 16), + + // Email Id + _buildLabel("Email Id"), + _buildTextField( + hintText: _email.isNotEmpty ? _email : "EX: gmail@gmail.com", + value: _email.isNotEmpty ? _email : null, + keyboardType: TextInputType.emailAddress, + readOnly: true, + disabled: true, + ), + + const SizedBox(height: 16), + + // Location + _buildLabel("Address"), + _buildTextField( + hintText: _address.isNotEmpty ? _address : " EX: R.s puram", + value: _address.isNotEmpty ? _address : null, + readOnly: true, + disabled: true, + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.all(width * 0.04), + child: SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + onPressed: _handleBackNavigation, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF5C1D8D), // Purple button color + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "Back", + style: TextStyle( + fontSize: 21, + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + ), + ); + } + + Future _handleBackNavigation() async { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + return; + } + final rootNavigator = Get.key.currentState; + if (rootNavigator != null && rootNavigator.canPop()) { + rootNavigator.pop(); + return; + } + if (Get.isOverlaysOpen) { + Get.back(closeOverlays: true); + return; + } + Get.back(); + } + + // Text label widget + Widget _buildLabel(String text, {bool required = false}) { + return Align( + alignment: Alignment.centerLeft, + child: RichText( + text: TextSpan( + text: text, + style: TextStyle( + fontSize: 20, + color: Colors.black, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + children: required + ? const [ + TextSpan( + text: " *", + style: TextStyle(color: Colors.red), + ), + ] + : [], + ), + ), + ); + } + + // Reusable TextField + Widget _buildTextField({ + required String hintText, + String? value, + TextInputType keyboardType = TextInputType.text, + required bool readOnly, + bool disabled = false, + }) { + return Container( + margin: const EdgeInsets.only(top: 6), + child: SizedBox( + height: 55, + width: 350, + child: TextFormField( + keyboardType: keyboardType, + readOnly: readOnly, + enabled: !disabled, + enableInteractiveSelection: false, + initialValue: value, + decoration: InputDecoration( + hintText: hintText, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/informations/rider_rewards_page.dart b/lib/xpress/views/Dashboard/profile/informations/rider_rewards_page.dart new file mode 100644 index 0000000..93a7807 --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/rider_rewards_page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:miler/xpress/controllers/rewards_controller.dart'; +import 'package:miler/xpress/views/Dashboard/profile/rewards_card.dart'; + +class RiderRewardsPage extends StatelessWidget { + const RiderRewardsPage({super.key}); + + @override + Widget build(BuildContext context) { + final RewardsController rewardsController = + Get.isRegistered() + ? Get.find() + : Get.put(RewardsController()); + + return Scaffold( + backgroundColor: Colors.grey.shade100, + appBar: AppBar( + title: Text( + "REWARDS", + style: TextStyle( + color: Colors.black, + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + backgroundColor: Colors.grey.shade100, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Navigator.pop(context), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + child: RewardsCard( + controller: rewardsController, + showFullDetails: true, + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/informations/saved_address.dart b/lib/xpress/views/Dashboard/profile/informations/saved_address.dart new file mode 100644 index 0000000..ccf52da --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/saved_address.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; + +class SavedAddressPage extends StatefulWidget { + const SavedAddressPage({super.key}); + + @override + State createState() => _SavedAddressPageState(); +} + +class _SavedAddressPageState extends State { + final TextEditingController _addressController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadAddress(); + } + + Future _loadAddress() async { + final prefs = await SharedPreferences.getInstance(); + final address = (prefs.getString('user_address') ?? '').trim(); + _addressController.text = address; + setState(() {}); + } + + @override + void dispose() { + _addressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final addressText = _addressController.text.trim(); + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FB), + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + elevation: 3, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + ), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Saved Address', + style: TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + ), + ), + ), + + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: size.width * 0.05, + vertical: 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 🏠 Header Section + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.15), + spreadRadius: 1, + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + color: ColorConstants.primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(10), + child: Icon( + Icons.location_on_rounded, + color: ColorConstants.primaryColor, + size: 26, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current Address', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + Text( + addressText.isNotEmpty + ? addressText + : 'No address saved yet.', + style: TextStyle( + fontSize: 15, + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade700, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 28), + + // ✨ Info Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: ColorConstants.primaryColor.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: ColorConstants.primaryColor, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Your saved address is used for deliveries, pickups, and nearby service accuracy.', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade800, + fontFamily: FontConstants.fontFamily, + height: 1.4, + ), + ), + ), + ], + ), + ), + + const Spacer(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/informations/support_ticket.dart b/lib/xpress/views/Dashboard/profile/informations/support_ticket.dart new file mode 100644 index 0000000..944227a --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/support_ticket.dart @@ -0,0 +1,659 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:miler/xpress/controllers/support_ticket.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; + +class SupportTicket extends StatefulWidget { + const SupportTicket({super.key}); + + @override + State createState() => _SupportTicketState(); +} + +class _SupportTicketState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + // Form + final _formKey = GlobalKey(); + final _subjectCtrl = TextEditingController(); + final _messageCtrl = TextEditingController(); + String _category = 'Account'; + String _priority = 'Medium'; + int _attachmentCount = 0; + + // Image + final ImagePicker _picker = ImagePicker(); + final List _attachments = []; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() => setState(() {})); + Get.put(SupportTicketController()); + } + + @override + void dispose() { + _subjectCtrl.dispose(); + _messageCtrl.dispose(); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Support Ticket', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + fontFamily: FontConstants.fontFamily, + ), + ), + elevation: 4, + ), + body: Column( + children: [ + Container( + color: Colors.white, + child: TabBar( + controller: _tabController, + indicatorColor: ColorConstants.primaryColor, + labelColor: ColorConstants.primaryColor, + unselectedLabelColor: Colors.grey, + labelStyle: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + tabs: [ + Tab( + child: Text( + 'Create Tickets', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 20, + ), + ), + ), + Tab( + child: Text( + 'My Tickets', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 20, + ), + ), + ), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [_buildCreateForm(), _buildTicketsList()], + ), + ), + ], + ), + bottomNavigationBar: _tabController.index == 0 + ? Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + height: 55, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: _submitTicket, + child: Text( + 'Submit Ticket', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ) + : null, + ), + ); + } + + // =============================================== + // CREATE FORM + // =============================================== + Widget _buildCreateForm() { + final controller = Get.find(); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Create a new support ticket', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + Text( + 'Tell us what went wrong. We\'ll get back to you as soon as possible.', + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade700, + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 20), + + // Category + Text('Category', style: _labelStyle()), + const SizedBox(height: 8), + DropdownButtonFormField( + // ignore: deprecated_member_use + value: _category, + items: ['Account', 'Orders', 'Payments', 'App issue', 'Other'] + .map( + (e) => DropdownMenuItem( + value: e, + child: Text( + e, + style: TextStyle(fontFamily: FontConstants.fontFamily), + ), + ), + ) + .toList(), + onChanged: (v) => setState(() => _category = v ?? _category), + decoration: _inputDecoration(), + ), + + const SizedBox(height: 16), + + // Priority + Text('Priority', style: _labelStyle()), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: ['Low', 'Medium', 'High'].map((p) { + final selected = _priority == p; + return ChoiceChip( + label: Text( + p, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + selected: selected, + selectedColor: ColorConstants.primaryColor.withOpacity(0.15), + labelStyle: TextStyle( + color: selected + ? ColorConstants.primaryColor + : Colors.black87, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + onSelected: (_) => setState(() => _priority = p), + ); + }).toList(), + ), + + const SizedBox(height: 16), + + // Subject + Text('Subject', style: _labelStyle()), + const SizedBox(height: 8), + TextFormField( + controller: _subjectCtrl, + decoration: _inputDecoration(hint: 'Type Something'), + style: TextStyle(fontFamily: FontConstants.fontFamily), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Please enter a subject' + : null, + ), + + const SizedBox(height: 16), + + // Message + Text('Describe the issue', style: _labelStyle()), + const SizedBox(height: 8), + TextFormField( + controller: _messageCtrl, + minLines: 5, + maxLines: 8, + decoration: _inputDecoration(hint: 'Type Something'), + style: TextStyle(fontFamily: FontConstants.fontFamily), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Please enter your message' + : null, + ), + + const SizedBox(height: 16), + + // Attachments + Row( + children: [ + OutlinedButton.icon( + onPressed: _addAttachment, + icon: const Icon(Icons.attach_file), + label: Text( + 'Add screenshot', + style: TextStyle(fontFamily: FontConstants.fontFamily), + ), + ), + const SizedBox(width: 12), + if (_attachmentCount > 0) + Text( + '$_attachmentCount attached', + style: TextStyle( + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + const SizedBox(height: 8), + if (_attachments.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _attachments.asMap().entries.map((entry) { + final idx = entry.key; + final file = entry.value; + return Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(file.path), + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + Positioned( + top: -8, + right: -8, + child: InkWell( + onTap: () => setState(() { + _attachments.removeAt(idx); + _attachmentCount = _attachments.length; + }), + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ); + }).toList(), + ), + + // Submit loading + Obx( + () => controller.isSubmitting.value + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + } + + // =============================================== + // MY TICKETS LIST + // =============================================== + Widget _buildTicketsList() { + final controller = Get.find(); + + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.errorMessage.value.isNotEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 12), + Text( + 'Failed to load tickets', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + controller.errorMessage.value, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey.shade600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: controller.fetchTickets, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (controller.tickets.isEmpty) { + return _buildEmptyState(); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + itemCount: controller.tickets.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, i) { + final t = controller.tickets[i]; + final statusColor = _getPriorityColor(t.priority); + + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + child: ListTile( + contentPadding: const EdgeInsets.all(12), + title: Text( + t.subject, + style: TextStyle( + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + 'Category: ${t.category} • Priority: ${t.priority}', + style: TextStyle(fontFamily: FontConstants.fontFamily), + ), + const SizedBox(height: 4), + Text( + 'Created: ${_formatDate(t.created)}', + style: TextStyle(fontFamily: FontConstants.fontFamily), + ), + ], + ), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + t.priority, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ); + }, + ); + }); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.support_agent, size: 48, color: Colors.grey), + const SizedBox(height: 12), + Text( + 'No tickets yet', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + Text( + 'Create your first ticket from the Create tab.', + style: TextStyle( + color: Colors.grey.shade700, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + // =============================================== + // HELPERS + // =============================================== + TextStyle _labelStyle() => TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ); + + InputDecoration _inputDecoration({String? hint}) { + return InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5), + ), + hintStyle: TextStyle(fontFamily: FontConstants.fontFamily), + ); + } + + Color _getPriorityColor(String priority) { + return switch (priority.toLowerCase()) { + 'high' => Colors.red, + 'medium' => Colors.orange, + 'low' => Colors.green, + _ => Colors.grey, + }; + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}'; + } + + // =============================================== + // IMAGE PICKER + // =============================================== + Future _addAttachment() async { + final source = await showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library), + title: Text( + 'Gallery', + style: TextStyle(fontFamily: FontConstants.fontFamily), + ), + onTap: () => Navigator.pop(ctx, ImageSource.gallery), + ), + ListTile( + leading: const Icon(Icons.camera_alt), + title: Text( + 'Camera', + style: TextStyle(fontFamily: FontConstants.fontFamily), + ), + onTap: () => Navigator.pop(ctx, ImageSource.camera), + ), + ], + ), + ), + ); + + if (source == null) return; + + try { + if (source == ImageSource.gallery) { + final multi = await _picker.pickMultiImage(imageQuality: 85); + if (multi.isNotEmpty) { + setState(() => _attachments.addAll(multi)); + } else { + final one = await _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, + ); + if (one != null) setState(() => _attachments.add(one)); + } + } else { + final captured = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 85, + ); + if (captured != null) setState(() => _attachments.add(captured)); + } + } catch (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to pick image', + style: TextStyle(fontFamily: FontConstants.fontFamily), + ), + ), + ); + } + + setState(() => _attachmentCount = _attachments.length); + } + + // =============================================== + // SUBMIT TICKET + // =============================================== + Future _submitTicket() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + final controller = Get.find(); + final success = await controller.createTicket( + userid: 1242, + category: _category, + priority: _priority, + subject: _subjectCtrl.text.trim(), + issue: _messageCtrl.text.trim(), + attachments: _attachments.isEmpty ? null : _attachments, + ); + + if (success) { + _subjectCtrl.clear(); + _messageCtrl.clear(); + _attachments.clear(); + _attachmentCount = 0; + setState(() {}); + + _tabController.animateTo(1); + + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Ticket Submitted!'), + content: const Text( + 'Your ticket has been created and saved. Our team will get back to you soon.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to submit ticket: ${controller.errorMessage.value}', + ), + backgroundColor: Colors.red, + ), + ); + } + } +} diff --git a/lib/xpress/views/Dashboard/profile/informations/terms_condition.dart b/lib/xpress/views/Dashboard/profile/informations/terms_condition.dart new file mode 100644 index 0000000..9882b74 --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/informations/terms_condition.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:get/get.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +// ===== Controller ===== +class TermsController extends GetxController { + WebViewController? webViewController; + var isLoading = true.obs; + + @override + void onInit() { + super.onInit(); + initializeWebView(); + } + + void initializeWebView() { + webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(const Color(0x00000000)) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (url) { + isLoading.value = true; + print('Started loading: $url'); + }, + onPageFinished: (url) { + isLoading.value = false; + print('Finished loading: $url'); + }, + onWebResourceError: (error) { + isLoading.value = false; + print('WebView error: ${error.description}'); + }, + ), + ); + loadTermsUrl(); + } + + Future loadTermsUrl() async { + if (webViewController != null) { + try { + await webViewController!.loadRequest( + Uri.parse('https://doormile.in/terms'), + ); + } catch (e) { + print('Error loading URL: $e'); + } + } + } +} + +// ===== Page ===== +class TermsCondition extends StatelessWidget { + const TermsCondition({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(TermsController()); + + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () { + Navigator.pop(context); + }, + ), + title: const Text( + 'Terms & Conditions', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: SafeArea( + child: Obx(() { + final wvc = controller.webViewController; + return Stack( + children: [ + if (wvc != null) WebViewWidget(controller: wvc), + if (controller.isLoading.value) + const LinearProgressIndicator(minHeight: 2), + ], + ); + }), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/profile/rewards_card.dart b/lib/xpress/views/Dashboard/profile/rewards_card.dart new file mode 100644 index 0000000..0a1f820 --- /dev/null +++ b/lib/xpress/views/Dashboard/profile/rewards_card.dart @@ -0,0 +1,492 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:miler/xpress/controllers/rewards_controller.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:miler/xpress/views/Dashboard/profile/informations/rider_rewards_page.dart'; + +class RewardsCard extends StatelessWidget { + final RewardsController controller; + final bool showFullDetails; + + const RewardsCard({ + super.key, + required this.controller, + this.showFullDetails = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Original Rewards Card (The Gradient Card) + GestureDetector( + onTap: () { + if (!showFullDetails) { + Get.to(() => const RiderRewardsPage()); + } + }, + child: Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + gradient: const LinearGradient( + colors: [ + Color(0xFF2C3E50), // Dark blue/grey + Color(0xFF4CA1AF), // Tealish + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Doormile Rewards", + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + Text( + "Ride more to get more rewards 🚴", + style: TextStyle( + fontSize: 14.sp, + color: Colors.white70, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 6.h, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: Colors.white30, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.stars_rounded, // Coin-like icon + color: Colors.amberAccent, + size: 24.sp, + ), + SizedBox(width: 8.w), + Obx(() { + return Text( + controller.isLoading.value + ? "..." + : "${controller.totalPoints.value}", + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.bold, + color: Colors.amberAccent, + fontFamily: FontConstants.fontFamily, + ), + ); + }), + ], + ), + ), + ], + ), + SizedBox(height: 16.h), + Container( + padding: EdgeInsets.all(12.r), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Keep riding correctly to get more points!", + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + Text( + "Earn 100 points to unlock new rewards", + style: TextStyle( + fontSize: 14.sp, + color: Colors.white70, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + Icon( + Icons.emoji_events, + color: Colors.amber, + size: 32.sp, + ), + ], + ), + ), + ], + ), + ), + ), + + if (showFullDetails) ...[ + SizedBox(height: 24.h), + + // 2. Surprise Gift Section + _buildSurpriseGiftCard(), + + SizedBox(height: 24.h), + + // 3. The 4 Cards Section + Text( + "Redeem Your Points", + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + SizedBox(height: 16.h), + + // Card 1: Data Recharge + _buildRewardOptionCard( + title: "Data Recharge", + subtitle: "Get free data for 1 month", + points: "300 Points", + icon: Icons.wifi, + color1: const Color(0xFF11998e), + color2: const Color(0xFF38ef7d), + ), + SizedBox(height: 16.h), + + // Card 2: Bonus Fuel + _buildRewardOptionCard( + title: "Bonus Fuel", + subtitle: "Fuel support for your vehicle", + points: "600 Points", + icon: Icons.local_gas_station, + color1: const Color(0xFFFF5F6D), + color2: const Color(0xFFFFC371), + ), + SizedBox(height: 16.h), + + // Card 3: Gadgets + _buildRewardOptionCard( + title: "Gadgets Support", + subtitle: "Powerbank or New Mobile support", + points: "1000 - 1500 Points", + icon: Icons.devices_other, + color1: const Color(0xFF2193b0), + color2: const Color(0xFF6dd5ed), + ), + SizedBox(height: 16.h), + + // Card 4: Vehicle Support + _buildRewardOptionCard( + title: "Vehicle Support", + subtitle: "New vehicle or 50% loan support", + points: "2000 Bonus Points", + description: "Earn 2000 bonus without any loses in bonus point", + icon: Icons.motorcycle, + color1: const Color(0xFF8E2DE2), + color2: const Color(0xFF4A00E0), + isPremium: true, + ), + + SizedBox(height: 30.h), + + // 4. Bottom Warning / Info Section + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: Colors.red.shade700, + size: 24.sp, + ), + SizedBox(width: 12.w), + Expanded( + child: Text( + "Note: If you miss any deliveries or if requirements are not met for each delivery, negative bonus points will affect your board.", + style: TextStyle( + fontSize: 15.sp, + color: Colors.red.shade900, + fontFamily: FontConstants.fontFamily, + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + ], + ); + } + + Widget _buildSurpriseGiftCard() { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: Colors.purple.withOpacity(0.1), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Stack( + children: [ + Positioned( + right: -20, + top: -20, + child: Icon( + Icons.card_giftcard, + size: 100.sp, + color: Colors.purple.withOpacity(0.05), + ), + ), + Padding( + padding: EdgeInsets.all(20.r), + child: Row( + children: [ + Container( + padding: EdgeInsets.all(12.r), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(12.r), + ), + child: Icon( + Icons.card_giftcard, + color: Colors.purple, + size: 30.sp, + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Monthly Surprise Gift", + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + SizedBox(height: 4.h), + Text( + "If you didn't skip any orders in 1 month!", + style: TextStyle( + fontSize: 14.sp, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRewardOptionCard({ + required String title, + required String subtitle, + required String points, + required IconData icon, + required Color color1, + required Color color2, + String? description, + bool isPremium = false, + }) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20.r), + child: Stack( + children: [ + // Decorative Background Circle + Positioned( + right: -30, + top: -30, + child: Container( + width: 120.w, + height: 120.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [color1.withOpacity(0.2), color2.withOpacity(0.0)], + begin: Alignment.bottomLeft, + end: Alignment.topRight, + ), + ), + ), + ), + + Padding( + padding: EdgeInsets.all(20.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: EdgeInsets.all(10.r), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color1, color2], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.white, size: 24.sp), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 6.h, + ), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: Colors.amber.shade200), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + points, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.bold, + color: Colors.amber.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + SizedBox(height: 16.h), + Text( + title, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + SizedBox(height: 4.h), + Text( + subtitle, + style: TextStyle( + fontSize: 15.sp, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + if (description != null) ...[ + SizedBox(height: 12.h), + Container( + padding: EdgeInsets.all(10.r), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Icon( + Icons.star_outline, + size: 16.sp, + color: Colors.blueGrey, + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + description, + style: TextStyle( + fontSize: 14.sp, + color: Colors.black87, + fontStyle: FontStyle.italic, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/xpress/views/Dashboard/summary/summary.dart b/lib/xpress/views/Dashboard/summary/summary.dart new file mode 100644 index 0000000..0a537dc --- /dev/null +++ b/lib/xpress/views/Dashboard/summary/summary.dart @@ -0,0 +1,590 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:miler/xpress/Models/summary/riderweeklykms.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; +import 'package:miler/xpress/controllers/summary_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/data/delivery_api.dart'; + +class Summary extends StatefulWidget { + const Summary({super.key}); + + @override + State createState() => _SummaryState(); +} + +class _SummaryState extends State { + final SummaryController controller = Get.put(SummaryController()); + int _userId = 0; + int _refreshTick = 0; + + @override + void initState() { + super.initState(); + _refreshData(); + } + + Future _refreshData() async { + try { + final prefs = await SharedPreferences.getInstance(); + final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + + if (mounted) { + setState(() { + _userId = uid; + }); + } + + if (uid > 0) { + await controller.fetchSummaryStats(uid); + } + } catch (e) { + debugPrint("❌ Error fetching summary: $e"); + } finally { + if (mounted) { + setState(() { + _refreshTick++; + }); + } + } + } + + // ------------------------ + // RESPONSIVE CARD + // ------------------------ + Widget _buildCard({ + required String title, + required String value, + required String imagePath, + bool isCancelled = false, + }) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isCancelled + ? const Color(0xFFFF5C5C) + : const Color.fromARGB(255, 159, 139, 163), + width: 1.2.w, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Image.asset( + imagePath, + height: 36.h, + width: 36.w, + color: isCancelled + ? const Color(0xFFFF5C5C) + : const Color(0xFF9C27B0), + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade700, + fontSize: 20.sp, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ), + SizedBox(height: 30.h), + Text( + value, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 29.sp, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + ], + ), + ), + ); + } + + // ------------------------ + // RESPONSIVE CANCELLED CARD + // ------------------------ + Widget _buildCancelledCard(String value) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: const Color.fromARGB(255, 232, 167, 167), + width: 1.3.w, + ), + borderRadius: BorderRadius.circular(10.r), + ), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + child: Row( + children: [ + // `assets/images/cancel.png` came across with the Xpress port and + // does not exist in this repo, so this card threw on every build for + // an Xpress tenant — a red exception box where the cancelled count + // should be. Drawn instead of loaded: the glyph is the same mark at + // the same size and cannot go missing. + Icon( + Icons.cancel_rounded, + size: 36.h, + color: const Color(0xFFFF5C5C), + ), + SizedBox(width: 15.w), + Expanded( + child: Text( + 'Cancelled Orders', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade700, + fontSize: 20.sp, + fontWeight: FontWeight.w400, + ), + ), + ), + Text( + value, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 34.sp, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + ], + ), + ); + } + + // ------------------------ + // MAIN UI + // ------------------------ + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + centerTitle: false, + toolbarHeight: 70.h, + title: Padding( + padding: EdgeInsets.only(top: 12.h), + child: Text( + "SUMMARY", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(1.h), + child: Divider(height: 1.h, color: Colors.grey), + ), + ), + body: Obx(() { + return RefreshIndicator( + onRefresh: _refreshData, + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.all(16.r), + child: Column( + children: [ + GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 12.w, + mainAxisSpacing: 12.h, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildCard( + title: 'Today', + value: controller.today.value.toString(), + imagePath: 'assets/images/today.png', + ), + _buildCard( + title: 'Week', + value: controller.week.value.toString(), + imagePath: 'assets/images/week.png', + ), + _buildCard( + title: 'Month', + value: controller.month.value.toString(), + imagePath: 'assets/images/week.png', + ), + _buildCard( + title: 'Total', + value: controller.total.value.toString(), + imagePath: 'assets/images/total.png', + ), + ], + ), + + SizedBox(height: 12.h), + _buildCancelledCard(controller.cancelled.value.toString()), + SizedBox(height: 12.h), + + Row( + children: [ + Text( + "Statistics", + style: TextStyle( + fontSize: 26.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ], + ), + + SizedBox(height: 10.h), + TotalDistanceCard(userId: _userId, refreshTick: _refreshTick), + ], + ), + ), + ); + }), + ), + ); + } +} + +// ======================================================== +// RESPONSIVE GRAPH CARD +// ======================================================== +class TotalDistanceCard extends StatefulWidget { + final int userId; + final int refreshTick; + + const TotalDistanceCard({ + super.key, + required this.userId, + this.refreshTick = 0, + }); + + @override + State createState() => _TotalDistanceCardState(); +} + +class _TotalDistanceCardState extends State { + late Future> _futureKms; + + @override + void initState() { + super.initState(); + _futureKms = _fetchWeeklyKms(); + } + + @override + void didUpdateWidget(covariant TotalDistanceCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId || + oldWidget.refreshTick != widget.refreshTick) { + setState(() { + _futureKms = _fetchWeeklyKms(); + }); + } + } + + double _toDouble(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + Future> _fetchWeeklyKms() async { + if (widget.userId == 0) { + return {'details': [], 'total_kms': 0.0}; + } + + try { + final uri = Uri.parse('${DeliveryApi.weeklyKms}?userid=${widget.userId}'); + final response = await http.get(uri); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + + if (data is Map && data['status'] == true) { + final rawDetails = (data['details'] is List) + ? data['details'] as List + : const []; + + final details = rawDetails + .map((e) => RiderWeeklyKms.fromJson(e)) + .toList(); + + final total = _toDouble(data['total_kms']); + return {'details': details, 'total_kms': total}; + } + } + } catch (e) { + debugPrint('❌ _fetchWeeklyKms Error: $e'); + } + + return {'details': [], 'total_kms': 0.0}; + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return FutureBuilder>( + future: _futureKms, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 24.h), + child: const Center( + child: CircularProgressIndicator(color: Colors.deepPurple), + ), + ); + } + + final List details = + snapshot.data?['details'] ?? []; + + final double totalKms = snapshot.data?['total_kms'] ?? 0.0; + + return _buildDistanceCard(details, totalKms, size); + }, + ); + } + + Widget _buildDistanceCard( + List details, + double totalKms, + Size size, + ) { + final double maxY = details.isEmpty ? 10 : _getMaxY(details); + final double chartHeight = (size.height * 0.25).clamp(160.h, 280.h); + final double leftInterval = _calculateInterval(maxY); + final double maxK = _getMaxKms(details); + + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: 4.h), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: const Color.fromARGB(255, 222, 161, 235), + width: 1.3.w, + ), + borderRadius: BorderRadius.circular(10.r), + ), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total Distance", + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + Text( + "${totalKms.toStringAsFixed(2)} Km", + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ], + ), + + SizedBox(height: 30.h), + + if (details.isEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: 8.h), + child: Center( + child: Text( + "No weekly data available", + style: TextStyle(color: Colors.grey, fontSize: 16.sp), + ), + ), + ), + + SizedBox( + height: chartHeight, + width: double.infinity, + child: BarChart( + BarChartData( + maxY: maxY, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.grey.withOpacity(0.12), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + alignment: BarChartAlignment.spaceAround, + + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 60.w, + interval: leftInterval, + getTitlesWidget: (value, _) => Text( + "${value.toInt()} km", + style: TextStyle( + fontSize: 14.sp, + color: Colors.black87, + ), + ), + ), + ), + + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, _) { + final idx = value.toInt(); + if (idx >= 0 && idx < details.length) { + return Text( + details[idx].day, + style: TextStyle( + fontSize: 14.sp, + color: Colors.black87, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ), + + barGroups: List.generate(details.isEmpty ? 7 : details.length, ( + i, + ) { + final kms = details.isEmpty ? 0.0 : details[i].kms.toDouble(); + + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: kms, + color: (details.isNotEmpty && kms == maxK) + ? const Color(0xFF8124DB) + : const Color(0xFFB274F3), + width: 20.w, + borderRadius: BorderRadius.circular(6.r), + ), + ], + ); + }), + + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + tooltipPadding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 8.h, + ), + getTooltipItem: (group, index, rod, rodIndex) { + return BarTooltipItem( + "${rod.toY.toStringAsFixed(2)} Km", + TextStyle( + color: Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.bold, + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } + + double _getMaxY(List details) { + if (details.isEmpty) return 10; + double maxVal = details.map((e) => e.kms).reduce(max); + if (maxVal <= 5) return 10; + final double withPadding = maxVal * 1.2; + return _roundUpNice(withPadding); + } + + double _roundUpNice(double v) { + final exponent = pow(10, (log(v) / ln10).floor()); + final mantissa = v / exponent; + + double niceMantissa; + if (mantissa <= 1) { + niceMantissa = 1; + } else if (mantissa <= 2) + niceMantissa = 2; + else if (mantissa <= 5) + niceMantissa = 5; + else + niceMantissa = 10; + + return (niceMantissa * exponent).ceilToDouble(); + } + + double _calculateInterval(double maxY) { + const int desiredTicks = 5; + double rough = max(1, (maxY / desiredTicks)); + + final exponent = pow(10, (log(rough) / ln10).floor()); + final mantissa = rough / exponent; + + double niceMantissa; + if (mantissa <= 1) { + niceMantissa = 1; + } else if (mantissa <= 2) + niceMantissa = 2; + else if (mantissa <= 5) + niceMantissa = 5; + else + niceMantissa = 10; + + return (niceMantissa * exponent).toDouble(); + } + + double _getMaxKms(List details) { + if (details.isEmpty) return 0; + return details.map((e) => e.kms).reduce(max); + } +} diff --git a/lib/xpress/views/helpers/constants/Colorconstants.dart b/lib/xpress/views/helpers/constants/Colorconstants.dart new file mode 100644 index 0000000..10b7ca3 --- /dev/null +++ b/lib/xpress/views/helpers/constants/Colorconstants.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// The delivery line's palette. +/// +/// ── Why these three values changed and nothing else did ── +/// +/// The delivery screens are a verbatim port of the Xpress-rider app: same +/// layout, same components, same 4-tab bottom bar. What was *not* kept is the +/// brand. Xpress-rider was purple (#662582) with a lilac tint (#E7D3EF), and a +/// rider signing in to Doormile should not be looking at another company's +/// colour — the whole point of the port is that both lines are Doormile. +/// +/// So the primary and its two tints are now the Doormile red the parcel line +/// uses (`lib/views/helpers/constants/Colorconstants.dart`, `primary`), and +/// every other entry below is untouched. Those others are semantic — greens for +/// success, ambers for categories — and carry no brand. +class ColorConstants { + /// Doormile red. Was #662582, Xpress-rider's purple. + static const primaryColor = Color(0xFF960019); + + /// The 8%-ish wash behind selected rows and icon badges. Was #E7D3EF, the + /// lilac derived from the purple; now the red's equivalent, so a tinted + /// surface still reads as "the brand, quietly" rather than as a second hue. + static Color? primaryColor1 = const Color(0xFFF6E0E2); + static Color? secondaryColor = Colors.white; + + /// Same wash as [primaryColor1], reached through the hex-string helper the + /// ported screens use on a handful of call sites. + static Color? ternaryColor = "#F6E0E2".toColor(); + static Color? darkGreyColor = "575756".toColor(); + static Color? lightGrey = "b2b2b2".toColor(); + static Color? lightGreyBg = Colors.grey.shade100; + static Color? greenColor = "00b894".toColor(); + static Color? mintColor = "69c0ac".toColor(); + static Color restaurantColor = Colors.amber[100]!; + static Color groceriesColor = Colors.purple[100]!; + static Color shoppingColor = Colors.orange[100]!; + static Color healthColor = Colors.cyan[100]!; + static Color handymanColor = Colors.red[100]!; + static const blueColor = 0xff007AC2; + static const redColor = 0xffEF3F42; + static const orangeColor = 0xffFAAB53; + static Color? lightColor = const Color.fromRGBO(244, 244, 244, 1); +} + +extension ColorExtenstion on String { + // ignore: body_might_complete_normally_nullable + Color? toColor() { + var hexColor = replaceAll("#", ""); + if (hexColor.length == 6) { + hexColor = "FF$hexColor"; + } + if (hexColor.length == 8) { + return Color(int.parse("0x$hexColor")); + } + } +} diff --git a/lib/xpress/views/helpers/constants/Font_constant.dart b/lib/xpress/views/helpers/constants/Font_constant.dart new file mode 100644 index 0000000..23d2842 --- /dev/null +++ b/lib/xpress/views/helpers/constants/Font_constant.dart @@ -0,0 +1,111 @@ +// ignore: file_names +import 'package:flutter/material.dart'; + +class FontConstants { + static String fontFamily = 'Proxima Nova'; + + // Base screen width for scaling (iPhone standard: 375) + static const double _baseWidth = 375.0; + + // Base font sizes (for baseWidth = 375) + static const double _baseExtraSmall = 10.0; + static const double _baseSmall = 12.0; + static const double _baseMedium = 14.0; + static const double _baseRegular = 16.0; + static const double _baseLarge = 20.0; + static const double _baseXLarge = 21.0; + static const double _baseXXLarge = 22.0; + static const double _baseXXXLarge = 24.0; + static const double _baseHuge = 30.0; + + /// Get fixed font size (no width scaling) so small & large phones look same. + static double getResponsiveFontSize(BuildContext context, double baseSize) { + return baseSize; + } + + /// Extra Small Text (10px base) - For labels, captions + static double extraSmall(BuildContext context) => + getResponsiveFontSize(context, _baseExtraSmall); + + /// Small Text (12px base) - For small labels, timestamps + static double small(BuildContext context) => + getResponsiveFontSize(context, _baseSmall); + + /// Medium Text (14px base) - For body text, descriptions + static double medium(BuildContext context) => + getResponsiveFontSize(context, _baseMedium); + + /// Regular Text (16px base) - Standard body text, most common + static double regular(BuildContext context) => + getResponsiveFontSize(context, _baseRegular); + + /// Large Text (18px base) - For subheadings, important text + static double large(BuildContext context) => + getResponsiveFontSize(context, _baseLarge); + + /// Extra Large Text (20px base) - For headings, titles + static double xLarge(BuildContext context) => + getResponsiveFontSize(context, _baseXLarge); + + /// 2X Large Text (22px base) - For main headings + static double xxLarge(BuildContext context) => + getResponsiveFontSize(context, _baseXXLarge); + + /// 3X Large Text (24px base) - For prominent headings + static double xxxLarge(BuildContext context) => + getResponsiveFontSize(context, _baseXXXLarge); + + /// Huge Text (26px base) - For hero text, very prominent headings + static double huge(BuildContext context) => + getResponsiveFontSize(context, _baseHuge); +} + +class ReusableTextWidget extends StatelessWidget { + final String text; + final double? fontSize; + final double? textHeight; + final String? fontFamily; + final FontWeight? fontWeight; + final FontStyle? fontStyle; + final Color? color; + final TextAlign? textAlign; + final int? maxLines; + final TextDecoration? isUnderText; + + const ReusableTextWidget({ + super.key, + required this.text, + this.fontSize, + this.textHeight, + this.fontFamily, + this.fontWeight, + this.fontStyle, + this.color, + this.textAlign, + this.maxLines, + this.isUnderText, + }); + + @override + Widget build(BuildContext context) { + return Text( + text, + softWrap: true, + style: TextStyle( + fontSize: fontSize ?? FontConstants.medium(context), + decoration: isUnderText, + fontFamily: fontFamily ?? FontConstants.fontFamily, + decorationColor: color, + fontWeight: fontWeight ?? FontWeight.normal, + fontStyle: fontStyle ?? FontStyle.normal, + color: color ?? Colors.grey.shade900, + overflow: TextOverflow.ellipsis, + decorationStyle: TextDecorationStyle.solid, + decorationThickness: 1, + height: textHeight, + ), + maxLines: maxLines, + textAlign: textAlign ?? TextAlign.start, + ); + } +} diff --git a/lib/xpress/views/helpers/constants/apiconstants.dart b/lib/xpress/views/helpers/constants/apiconstants.dart new file mode 100644 index 0000000..21814d7 --- /dev/null +++ b/lib/xpress/views/helpers/constants/apiconstants.dart @@ -0,0 +1,81 @@ +import 'package:miler/xpress/data/delivery_api.dart'; + +/// The delivery line's endpoint names, kept as a thin façade over [DeliveryApi]. +/// +/// ── Why this class still exists ── +/// +/// The ported screens reference `ApiConstants.currentDeliveryLive` and friends +/// on roughly thirty call sites, most of them inside 1000-line `part` files that +/// were brought over verbatim. Renaming those call sites would mean editing the +/// ported flow, which is the one thing this port was meant not to do. +/// +/// So the names survive and the values now resolve to Doormile routes. Every +/// URL — including the ones that used to be inline in `homepage.dart`, +/// `cartpage.dart`, `deliveries.dart` and `summary.dart` — is defined once, in +/// [DeliveryApi], with a LIVE / MAPPED / ABSENT marker saying whether the route +/// exists on the backend today. Read that file before trusting any call here. +/// +/// ── The dev/live pairs collapsed ── +/// +/// The old backend had a `dev` and a `live` deployment selected by a string +/// (`mainRoute`), so every endpoint came in two. Doormile has one API and one +/// base URL, so both members of each pair now return the same value. They are +/// kept only so the ported call sites — which branch on +/// `ApiConstants.mainRoute == 'live'` — keep compiling and land on the same +/// place either way. +class ApiConstants { + /// Retained because ported call sites branch on it. Both branches now resolve + /// to the same Doormile route. + static String mainDev = "dev"; + static String mainRoute = "live"; + + //Delivery Queue — MAPPED to /miler/assignments + static String get deliveryQueueDev => DeliveryApi.deliveryQueue; + static String get deliveryQueueLive => DeliveryApi.deliveryQueue; + + //Current Delivery — MAPPED to /miler/bookings + static String get currentDeliveryDev => DeliveryApi.currentDeliveries; + static String get currentDeliveryLive => DeliveryApi.currentDeliveries; + + //Current Delivery V3 (was date-bounded) — MAPPED to /miler/bookings + static String get currentDeliveryV3Dev => DeliveryApi.currentDeliveries; + static String get currentDeliveryV3Live => DeliveryApi.currentDeliveries; + + //Update Delivery — ABSENT, see DeliveryApi.updateDelivery + static String get updateDeliveryDev => DeliveryApi.updateDelivery; + static String get updateDeliveryLive => DeliveryApi.updateDelivery; + + //Get Rider Log — LIVE + static String get getRiderLogDev => DeliveryApi.getRiderLog; + static String get getRiderLogLive => DeliveryApi.getRiderLog; + + //Create Rider Log — LIVE + static String get createRiderLogDev => DeliveryApi.createRiderLog; + static String get createRiderLogLive => DeliveryApi.createRiderLog; + + //Update Rider Log — LIVE (same collection, PUT semantics at the call site) + static String get updateRiderLogDev => DeliveryApi.createRiderLog; + static String get updateRiderLogLive => DeliveryApi.createRiderLog; + + //Get Rider Count — ABSENT on Doormile; nearest real read is duty status. + static String get getRiderCountDev => DeliveryApi.currentDuty; + static String get getRiderCountLive => DeliveryApi.currentDuty; + + //Create Break Rider Log — LIVE + static String get createBreakRiderLogDev => DeliveryApi.startBreak; + static String get createBreakRiderLogLive => DeliveryApi.startBreak; + + //Update Break Rider Log — LIVE + static String get updateBreakRiderLogDev => DeliveryApi.endBreak; + static String get updateBreakRiderLogLive => DeliveryApi.endBreak; + + //Create Delivery Log — ABSENT, see DeliveryApi.createDeliveryLog + static String get createDeliveryLogDev => DeliveryApi.createDeliveryLog; + static String get createDeliveryLogLive => DeliveryApi.createDeliveryLog; + + //Summary — LIVE (/miler/earnings) + static String get summaryApiLive => DeliveryApi.earnings; + + //Summary Rider Weekly KMs — ABSENT, see DeliveryApi.weeklyKms + static String get summaryriderkmLive => DeliveryApi.weeklyKms; +} diff --git a/lib/xpress/views/helpers/constants/mqtt_constants.dart b/lib/xpress/views/helpers/constants/mqtt_constants.dart new file mode 100644 index 0000000..2da9a8b --- /dev/null +++ b/lib/xpress/views/helpers/constants/mqtt_constants.dart @@ -0,0 +1,19 @@ +class MqttConstants { + static const String brokerHost = '66.116.225.226'; // Updated with VPS IP + static const int brokerPort = 1883; + static const String username = 'admin'; + static const String passwordString = 'Package@321#'; // Provided by user + + // Topic Structure + static const String topicRiderStatus = 'doormile/riders/{riderId}/status'; + static const String topicRiderProfile = 'doormile/riders/{riderId}/profile'; + static const String topicRiderLocation = 'doormile/riders/{riderId}/location'; + static const String topicRiderTelemetry = + 'doormile/riders/{riderId}/telemetry'; + static const String topicRiderLogs = 'doormile/riders/{riderId}/logs'; + + // Status Values + static const String statusOnline = 'Online'; + static const String statusOffline = 'Offline'; + static const String statusIdle = 'Idle'; +} diff --git a/lib/xpress/widget/Bottom_page.dart b/lib/xpress/widget/Bottom_page.dart new file mode 100644 index 0000000..1f99abf --- /dev/null +++ b/lib/xpress/widget/Bottom_page.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:miler/xpress/views/Dashboard/deliveries/deliveries.dart'; +import 'package:miler/xpress/views/Dashboard/home/homepage.dart'; +import 'package:miler/xpress/views/Dashboard/summary/summary.dart'; +import 'package:miler/xpress/views/Dashboard/profile/Profilepage.dart'; +import 'package:miler/xpress/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/xpress/views/helpers/constants/Font_constant.dart'; + +class BottomPage extends StatefulWidget { + final int initialIndex; + final Widget? overridePage; + final List acceptedOrders; + const BottomPage({ + super.key, + this.initialIndex = 0, + this.overridePage, + this.acceptedOrders = const [], + }); + @override + State createState() => _BottomPageState(); +} + +class _BottomPageState extends State { + late int selected; + late final List _pages; + @override + void initState() { + super.initState(); + selected = widget.initialIndex; + _pages = [ + widget.overridePage ?? const Homepage(), + const MyDeliveries(), + const Summary(), // Removed Cartpage - active deliveries now shown in deliveries page banner + const ProfilePage(), + ]; + } + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + textTheme: Theme.of( + context, + ).textTheme.apply(fontFamily: FontConstants.fontFamily), + ), + child: Scaffold( + body: IndexedStack(index: selected, children: _pages), + bottomNavigationBar: Container( + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, -2), + ), + ], + ), + child: BottomNavigationBar( + backgroundColor: Colors.white, + currentIndex: selected, + onTap: (index) => setState(() => selected = index), + type: BottomNavigationBarType.fixed, + selectedItemColor: ColorConstants.primaryColor, + unselectedItemColor: Colors.grey, + selectedFontSize: 14, + unselectedFontSize: 12, + showUnselectedLabels: true, + items: const [ + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/homeicon.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selecthome.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'HOME', + ), + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/deliveryicon.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selecteddelivery.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'DELIVERIES', + ), + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/summary.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selectedsummary.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'SUMMARY', + ), + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/profileicon.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selectedprofile.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'PROFILE', + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 4260351..7f07db0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.3" + animated_toggle_switch: + dependency: "direct main" + description: + name: animated_toggle_switch + sha256: faecb2339cde5e7d299389cf7e01cf35305a3ba212ae8ff51c02ce8385d1d273 + url: "https://pub.dev" + source: hosted + version: "0.8.7" + another_flushbar: + dependency: "direct main" + description: + name: another_flushbar + sha256: d7a8cb2a131fcaf1b84e0afa19765db7cd8717b6b0649c8decc909524cbda599 + url: "https://pub.dev" + source: hosted + version: "2.2.4" ansicolor: dependency: transitive description: @@ -804,10 +820,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" image_picker: dependency: "direct main" description: @@ -944,6 +960,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.2" + lucide_icons_flutter: + dependency: "direct main" + description: + name: lucide_icons_flutter + sha256: "6e2eb4a11137851be84a57e936fc864d565709ec0ae854b786b15397d1b66276" + url: "https://pub.dev" + source: hosted + version: "3.1.17" + maplibre_gl: + dependency: "direct main" + description: + name: maplibre_gl + sha256: b676f124a2fcf88c4dafedc7b462155e6396d61ce7af46354b4de11b45c9812f + url: "https://pub.dev" + source: hosted + version: "0.26.2" + maplibre_gl_platform_interface: + dependency: transitive + description: + name: maplibre_gl_platform_interface + sha256: "1f0ca8a99f03fa9434618ee21f4e42dd615830a7dd973e632b11c73ffec993a8" + url: "https://pub.dev" + source: hosted + version: "0.26.2" + maplibre_gl_web: + dependency: transitive + description: + name: maplibre_gl_web + sha256: bbf022f29ceef26d73f63e584819fbd02fbaf4eb1facf5234206c78936cf8f1c + url: "https://pub.dev" + source: hosted + version: "0.26.2" matcher: dependency: transitive description: @@ -1304,6 +1352,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.4" + skeletonizer: + dependency: "direct main" + description: + name: skeletonizer + sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122" + url: "https://pub.dev" + source: hosted + version: "2.1.3" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index da896df..1b0faca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: flutter_screenutil: ^5.9.3 google_fonts: ^6.3.3 shimmer: ^3.0.0 + skeletonizer: ^2.1.3 flutter_launcher_icons: ^0.14.4 scratcher: ^2.5.0 confetti: ^0.8.0 @@ -65,6 +66,15 @@ dependencies: mqtt_client: ^10.3.0 flutter_map: ^8.3.1 latlong2: ^0.10.1 + # The delivery line's maps. The parcel line draws its maps with flutter_map + # (OSM tiles + OSRM, keyless — see views/helpers/widgets/miler_map.dart); the + # ported delivery screens under lib/xpress were built against MapLibre and are + # kept verbatim, so both engines ship. Only one line runs per session, so only + # one is ever instantiated. + maplibre_gl: ^0.26.2 + animated_toggle_switch: ^0.8.7 + another_flushbar: ^2.2.4 + lucide_icons_flutter: ^3.1.17 dev_dependencies: flutter_test: @@ -102,25 +112,48 @@ flutter: - assets/lotties/ fonts: + # The delivery line's typeface. The ported screens under lib/xpress read + # FontConstants.fontFamily = 'Proxima Nova' on every text style; the files + # were already in assets/fonts but the family was never declared, so without + # this every delivery screen falls back to Roboto. + - family: Proxima Nova + fonts: + - asset: assets/fonts/ProximaNova/proximanova_regular.ttf + weight: 400 + - asset: assets/fonts/ProximaNova/proximanova_bold.otf + weight: 700 - family: Manrope fonts: - asset: assets/fonts/Manrope/Manrope-VF.ttf - family: PlusJakartaSans fonts: - asset: assets/fonts/PlusJakartaSans/PlusJakartaSans-VF.ttf - - family: poppins + # ── The app's typeface ── + # + # Declared as `poppins` with a lowercase path on every asset — and the files + # on disk are `assets/fonts/Poppins/Poppins-*.ttf`. Asset bundling is + # case-sensitive on both platforms, so not one of these seven resolved and + # the family had never rendered a single glyph. Anything asking for it fell + # back to Roboto, silently. + # + # All seven static weights are declared because Poppins ships as separate + # files rather than a variable font like Manrope did. The app leans on w800 + # and w900 for headings; without those two entries Flutter would synthesise + # them by smearing SemiBold, which is what makes a heading look muddy at + # exactly the size it is meant to be read from. + - family: Poppins fonts: - - asset: assets/fonts/poppins/poppins-Light.ttf + - asset: assets/fonts/Poppins/Poppins-Light.ttf weight: 300 - - asset: assets/fonts/poppins/poppins-Regular.ttf + - asset: assets/fonts/Poppins/Poppins-Regular.ttf weight: 400 - - asset: assets/fonts/poppins/poppins-Medium.ttf + - asset: assets/fonts/Poppins/Poppins-Medium.ttf weight: 500 - - asset: assets/fonts/poppins/poppins-SemiBold.ttf + - asset: assets/fonts/Poppins/Poppins-SemiBold.ttf weight: 600 - - asset: assets/fonts/poppins/poppins-Bold.ttf + - asset: assets/fonts/Poppins/Poppins-Bold.ttf weight: 700 - - asset: assets/fonts/poppins/poppins-ExtraBold.ttf + - asset: assets/fonts/Poppins/Poppins-ExtraBold.ttf weight: 800 - - asset: assets/fonts/poppins/poppins-Black.ttf + - asset: assets/fonts/Poppins/Poppins-Black.ttf weight: 900 diff --git a/test/accepted_count_test.dart b/test/accepted_count_test.dart new file mode 100644 index 0000000..a8406bd --- /dev/null +++ b/test/accepted_count_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE FLOATING COUNT IS DERIVED, NOT KEPT +/// +/// It read the length of a persisted "accepted bookings" list. That store is +/// written on accept and cleaned on completion, so a stop that left the day any +/// other way — the hub pulling it, a reject racing a refetch, yesterday's run — +/// stayed in it and the pill counted work the rider did not have. Two accepted +/// stops could read as twelve. +/// +/// The trip already knows. This pins the derivation the pill now uses, against +/// the same accepted/rejected sets every other surface on Home reads. +/// ───────────────────────────────────────────────────────────────────────── +int waiting( + Trip trip, { + required Set accepted, + Set rejected = const {}, +}) => trip + .states(acceptedIds: accepted, rejectedIds: rejected) + .where((s) => s == StopState.accepted || s == StopState.collected) + .length; + +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map stop(String id, {String status = 'new'}) => { + 'orderid': id, + 'pickupcustomer': 'Stop $id', + 'pickupaddress': '12 Main Rd, Coimbatore', + 'orderstatus': status, + 'Quantity': 1, + }; + + Trip four() => Trip.fromStops( + id: 't', + stops: [stop('A'), stop('B'), stop('C'), stop('D')], + ); + + test('nothing accepted is zero, not the size of the day', () { + expect(waiting(four(), accepted: const {}), 0); + }); + + test('two accepted reads exactly two', () { + expect(waiting(four(), accepted: const {'A', 'B'}), 2); + }); + + test('five accepted reads exactly five', () { + final trip = Trip.fromStops( + id: 't', + stops: [for (var i = 0; i < 6; i++) stop('S$i')], + ); + expect(waiting(trip, accepted: {for (var i = 0; i < 5; i++) 'S$i'}), 5); + }); + + test('rejecting one drops it in the same frame', () { + final trip = four(); + expect(waiting(trip, accepted: const {'A', 'B', 'C'}), 3); + // The rider declines C. Same trip, same accepted set — the rejected set is + // what changed, and a rejection outranks a stale accept. + expect( + waiting(trip, accepted: const {'A', 'B', 'C'}, rejected: const {'C'}), + 2, + ); + }); + + test('a finished stop stops counting', () { + final trip = Trip.fromStops( + id: 't', + stops: [ + stop('A', status: 'picked'), + stop('B'), + ], + ); + expect( + waiting(trip, accepted: const {'A', 'B'}), + 1, + reason: 'A is done; only B is still waiting on the work tab', + ); + }); + + test('the stop he is standing at is not "waiting for him"', () { + final trip = Trip.fromStops( + id: 't', + stops: [ + stop('A', status: 'active'), + stop('B'), + ], + ); + expect(waiting(trip, accepted: const {'A', 'B'}), 1); + }); + + test('a cancelled stop stops counting', () { + final trip = Trip.fromStops( + id: 't', + stops: [ + stop('A', status: 'cancelled'), + stop('B'), + ], + ); + expect(waiting(trip, accepted: const {'A', 'B'}), 1); + }); +} diff --git a/test/accepted_pill_test.dart b/test/accepted_pill_test.dart index da978d6..ced9b94 100644 --- a/test/accepted_pill_test.dart +++ b/test/accepted_pill_test.dart @@ -11,7 +11,11 @@ import 'package:miler/views/helpers/constants/design_constants.dart'; /// stops. The pill replaces it: same fixed place every time, and it says how /// many bookings the rider is holding. void main() { - Future pump(WidgetTester tester, int count, {VoidCallback? onTap}) async { + Future pump( + WidgetTester tester, + int count, { + VoidCallback? onTap, + }) async { tester.view.physicalSize = const Size(1170, 2532); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); diff --git a/test/account_page_test.dart b/test/account_page_test.dart new file mode 100644 index 0000000..ddf612a --- /dev/null +++ b/test/account_page_test.dart @@ -0,0 +1,280 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/controllers/rewards_controller.dart'; +import 'package:miler/controllers/summary_controller.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/profile/Profilepage.dart'; +import 'package:miler/views/Dashboard/profile/informations/help_center.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/settings_ui.dart'; + +/// ── The rider's control centre, not a settings screen ── +/// +/// Account opened on four boxes: an outlined card holding the rider's name, and +/// three more side by side holding two figures and a constant, each with a +/// saturated brand glyph on it. Under them, twelve rows whose icons were drawn +/// in the same near-black as the words they labelled, and at the bottom a +/// red-bordered notice with Logout tucked under it in the brand colour, at the +/// same weight as the row that picks an alert tone. +/// +/// It is one page on one surface now: the identity on the paper, the figures on +/// a single quiet fill, the icons demoted under the titles they mark, and the +/// one row that ends the session set apart and drawn destructive. +/// +/// `profile_pages_test.dart` holds the bars on every screen this page opens. +/// This file holds the page itself. +void main() { + Future pump( + WidgetTester tester, { + Map prefs = const {}, + ServiceProfile profile = ServiceProfile.parcel, + int points = 1240, + int pickups = 318, + String figuresError = '', + double height = 1600, + }) async { + ServiceProfile.setActive(profile); + // 0 keeps the page from firing its two network fetches, so the figures + // seeded below are the ones under test. + SharedPreferences.setMockInitialValues({'userid': 0, ...prefs}); + + Get.put(RewardsController()) + ..totalPoints.value = points + ..isLoading.value = false + ..error.value = figuresError; + Get.put(SummaryController()) + ..total.value = pickups + ..isLoading.value = false; + + tester.view.physicalSize = Size(390 * 3, height * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: ProfilePage()), + ), + ); + await tester.pumpAndSettle(); + } + + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + const rider = { + 'user_name': 'Joe Mathew', + 'user_email': 'joe.mathew@doormile.in', + 'contactno': '+91 98765 43210', + }; + + testWidgets('identity leads, and it is not a card', (tester) async { + await pump(tester, prefs: rider); + + expect(find.text('Joe Mathew'), findsOneWidget); + expect(find.text('+91 98765 43210'), findsOneWidget); + + // The header, the three figures and every section used to be a + // `SettingsCard`. Nothing on this page is one now — the only fill above the + // fold is the strip holding the figures, and it carries no outline. + expect(find.byType(SettingsCard), findsNothing); + + // "View profile" was a second control for the destination the row already + // goes to, taking a third line to say so. + expect(find.text('View profile'), findsNothing); + }); + + testWidgets('the line of work is read, never named', (tester) async { + await pump(tester, prefs: rider); + expect(find.text(ServiceProfile.parcel.label), findsOneWidget); + expect(find.text('Bookings'), findsOneWidget); + expect(find.text('What a new booking sounds like'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + Get.reset(); + + await pump(tester, prefs: rider, profile: ServiceProfile.milkMan); + expect(find.text(ServiceProfile.milkMan.label), findsOneWidget); + // A milk-run rider does not take bookings, and every stop at a customer's + // door is a drop. Both strings follow `ServiceProfile.active`. + expect(find.text('Deliveries'), findsOneWidget); + expect(find.text('What a new delivery sounds like'), findsOneWidget); + expect(find.text('Bookings'), findsNothing); + }); + + testWidgets('a rider who has entered nothing is told nothing', ( + tester, + ) async { + await pump(tester); + + // These defaulted to `rider@miler.com` and `Not added` and were rendered as + // facts — so a new rider's own account page stated an email he had never + // given. A field with nothing behind it is a field the page does not draw. + expect(find.textContaining('rider@miler.com'), findsNothing); + expect(find.text('Not added'), findsNothing); + // The row it belonged to is still there, without its second line. + expect(find.text('Edit Profile'), findsOneWidget); + }); + + testWidgets('a figure that could not be fetched is a gap, not a zero', ( + tester, + ) async { + await pump(tester, prefs: rider, figuresError: "Couldn't load your points"); + + expect(find.text('—'), findsOneWidget); + expect(find.text('1,240'), findsNothing); + + // Compact and actionable: one line and a retry, not a screen the whole tab + // has to sit in — everything else on this page is read off the phone. + expect(find.text('Could not refresh these figures'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('ending the session is not a preference', (tester) async { + await pump(tester, prefs: rider); + + final logout = tester.widget(find.text('Logout')); + // Brand maroon is navigation and identity; red destroys. Logout was drawn + // in the brand colour, which made the row that ends the session the same + // colour as the wordmark above it. + expect(logout.style!.color, ColorConstants.errorRed); + + // Nothing is being navigated to, so there is no chevron on it — and it sits + // below a rule with a screenful of air above, rather than 18pt under the + // emergency banner in the shape of a settings group. + final row = find.ancestor( + of: find.text('Logout'), + matching: find.byType(SettingsRow), + ); + expect(tester.widget(row).danger, isTrue); + + expect( + tester.getRect(find.text('Logout')).top - + tester.getRect(find.byType(EmergencyRow)).bottom, + greaterThan(30), + ); + }); + + testWidgets('the emergency row is a wash, not an outline', (tester) async { + await pump(tester, prefs: rider); + + final box = tester.widget( + find + .descendant( + of: find.byType(EmergencyRow), + matching: find.byType(Container), + ) + .first, + ); + final decoration = box.decoration! as BoxDecoration; + expect(decoration.border, isNull, reason: 'no red outline'); + expect(decoration.color, isNot(ColorConstants.primary)); + + // The words are the page's own near-black so they read at a glance; the + // red is spent on the two marks that carry the meaning. + expect( + tester.widget(find.text('Emergency help')).style!.color, + ColorConstants.slateText, + ); + expect( + find.text('Calls 112 if you are in immediate danger'), + findsOneWidget, + ); + }); + + testWidgets('Help & Support draws the same emergency row', (tester) async { + SharedPreferences.setMockInitialValues({'userid': 0}); + tester.view.physicalSize = const Size(390 * 3, 900 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: HelpCenter()), + ), + ); + await tester.pumpAndSettle(); + + // It used to be a second, differently-worded copy of the same banner. The + // sentence promising a rider what a press will do is not a thing to have + // two versions of. + expect(find.byType(EmergencyRow), findsOneWidget); + expect(find.text('Emergency help'), findsOneWidget); + }); + + testWidgets('every row is a target a thumb can hit', (tester) async { + await pump(tester, prefs: rider); + + for (final title in const [ + 'Earnings', + 'Edit Profile', + 'Saved Address', + 'Alert Sound', + 'Notifications', + 'Help & Support', + 'FAQ', + 'Terms & Conditions', + 'Logout', + ]) { + final row = find.ancestor( + of: find.text(title), + matching: find.byType(SettingsRow), + ); + expect(row, findsOneWidget, reason: '"$title" is a row'); + expect( + tester.getSize(row).height, + greaterThanOrEqualTo(ButtonSizes.minTapTarget), + reason: '"$title" is smaller than a fingertip', + ); + } + }); + + testWidgets('the icons do not outrank the titles they mark', (tester) async { + await pump(tester, prefs: rider); + + // Twelve glyphs in `onSurface` read as loudly as the twelve things they + // were labelling. The title leads; the icon locates it. + final icon = tester.widget( + find + .descendant( + of: find.ancestor( + of: find.text('Saved Address'), + matching: find.byType(SettingsRow), + ), + matching: find.byType(Icon), + ) + .first, + ); + expect(icon.color, ColorConstants.secondaryText); + expect(icon.color, isNot(ColorConstants.onSurface)); + }); + + testWidgets('a skeleton fills what it is given and stops', (tester) async { + // Five placeholder cards are ~820pt tall and the body of a pushed page on + // the shortest supported phone is ~768, so the screen whose whole job is to + // say "this is coming" greeted the rider with an overflow band. + tester.view.physicalSize = const Size(390 * 3, 700 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => + const MaterialApp(home: Scaffold(body: SkeletonList(count: 5))), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/active_pickup_banner_test.dart b/test/active_pickup_banner_test.dart index 44338d7..029670e 100644 --- a/test/active_pickup_banner_test.dart +++ b/test/active_pickup_banner_test.dart @@ -76,21 +76,27 @@ void main() { /// Gap between the bottom of the visible card and the bottom of the screen. double gapBelowCard(WidgetTester tester) { - final screen = tester.view.physicalSize.height / tester.view.devicePixelRatio; + final screen = + tester.view.physicalSize.height / tester.view.devicePixelRatio; return screen - tester.getRect(find.byType(Material).last).bottom; } - testWidgets('clears the floating nav bar while the bar is showing', - (tester) async { + testWidgets('clears the floating nav bar while the bar is showing', ( + tester, + ) async { await pump(tester); final context = tester.element(find.byType(ActivePickupBanner)); final required = BottomPage.bottomInset(context); - expect(gapBelowCard(tester), greaterThanOrEqualTo(required), - reason: 'the card must sit above the nav bar, which claims ' - '${required.toStringAsFixed(1)}pt at the foot of the page. It was ' - 'pinned to bottom: 0 and the bar covered it.'); + expect( + gapBelowCard(tester), + greaterThanOrEqualTo(required), + reason: + 'the card must sit above the nav bar, which claims ' + '${required.toStringAsFixed(1)}pt at the foot of the page. It was ' + 'pinned to bottom: 0 and the bar covered it.', + ); }); testWidgets('sits at one height rather than chasing the bar', (tester) async { @@ -106,13 +112,18 @@ void main() { expect(gapBelowCard(tester), closeTo(resting, 0.5)); final context = tester.element(find.byType(ActivePickupBanner)); - expect(resting, greaterThanOrEqualTo(BottomPage.bottomInset(context)), - reason: 'it must clear the bar at its resting height, which is the ' - 'height the bar returns to the moment he scrolls back up'); + expect( + resting, + greaterThanOrEqualTo(BottomPage.bottomInset(context)), + reason: + 'it must clear the bar at its resting height, which is the ' + 'height the bar returns to the moment he scrolls back up', + ); }); - testWidgets('lines up with the nav bar rather than running full width', - (tester) async { + testWidgets('lines up with the nav bar rather than running full width', ( + tester, + ) async { await pump(tester); final card = tester.getRect(find.byType(Material).last); // The nav bar sits on a 16 gutter; the banner stacks directly under it. @@ -131,11 +142,15 @@ void main() { width: width, textScale: scale, count: 3, - address: '19/4B, Thadagam Main Road, Venkitapuram, Coimbatore, ' + address: + '19/4B, Thadagam Main Road, Venkitapuram, Coimbatore, ' 'Tamil Nadu 641025', ); - expect(tester.takeException(), isNull, - reason: 'the banner overflowed at ${width.toInt()}pt / ${scale}x'); + expect( + tester.takeException(), + isNull, + reason: 'the banner overflowed at ${width.toInt()}pt / ${scale}x', + ); }); } } @@ -150,10 +165,14 @@ void main() { testWidgets('names the count and the stop status', (tester) async { await pump(tester, count: 3); expect(find.text('3 Active Pickups'), findsOneWidget); - expect(find.textContaining('At the stop'), findsOneWidget, - reason: 'the rider should see how far into the stop he already is. The ' - 'wording comes from StopStatusX.label, the one place the app names ' - 'its statuses — and it is "At the stop" rather than the banner\'s ' - 'old private "At pickup", which was wrong on a delivery'); + expect( + find.textContaining('At the stop'), + findsOneWidget, + reason: + 'the rider should see how far into the stop he already is. The ' + 'wording comes from StopStatusX.label, the one place the app names ' + 'its statuses — and it is "At the stop" rather than the banner\'s ' + 'old private "At pickup", which was wrong on a delivery', + ); }); } diff --git a/test/active_stop_row_test.dart b/test/active_stop_row_test.dart new file mode 100644 index 0000000..c35b32f --- /dev/null +++ b/test/active_stop_row_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// The stop being worked lives in the trip list, not on a floating strip. +/// +/// Home used to hide an active parcel stop from its own route and announce it +/// instead with a red "Active Pickup" banner pinned over the bottom of the +/// page — which covered the last card, said less than the row would have, and +/// whose tap only refetched the queue. These tests are the two halves of that +/// being one thing now: the row shows, and the row is the way in. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map stop({required String id, required String status}) => { + 'orderid': id, + 'step': 1, + 'pickupcustomer': 'Customer $id', + 'pickupaddress': '1 Main Road, RS Puram', + 'orderstatus': status, + 'type': 'pickup', + 'pickuplat': 13.08, + 'pickuplon': 80.21, + }; + + Future pumpRow( + WidgetTester tester, { + required String status, + void Function(Map)? onContinue, + }) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final trip = Trip.fromStops( + id: 't', + stops: [stop(id: '1', status: status)], + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + onToggleSelect: (_) {}, + onContinueStop: onContinue, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + } + + testWidgets('a parcel stop under way stays on Home, marked Active', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.parcel); + await pumpRow(tester, status: 'active', onContinue: (_) {}); + + // It used to be filtered out of the list entirely on this tenant. + expect(find.text('Customer 1'), findsOneWidget); + expect(find.text('Active'), findsOneWidget); + }); + + testWidgets('the live row carries the way back into the stop', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.parcel); + Map? continued; + await pumpRow(tester, status: 'active', onContinue: (s) => continued = s); + + // Not "finish it in Bookings" beside a banner that was the only real way + // through: the row he is looking at is the control. + expect(find.text('In progress — finish it in Bookings'), findsNothing); + // The control is an icon in the card's action cluster now — labelled, so + // it is found the way a screen reader finds it rather than by its glyph. + await tester.tap(find.bySemanticsLabel('Continue this stop')); + await tester.pump(); + expect(continued?['orderid'], '1'); + }); + + testWidgets('with nowhere to continue to, the row states its state', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.parcel); + await pumpRow(tester, status: 'active'); + + expect(find.bySemanticsLabel('Continue this stop'), findsNothing); + expect(find.text('In progress — finish it in Bookings'), findsOneWidget); + }); + + testWidgets('a service run marks its live stop the same way', (tester) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpRow(tester, status: 'active', onContinue: (_) {}); + + expect(find.text('Customer 1'), findsOneWidget); + expect(find.text('Active'), findsOneWidget); + }); + + testWidgets('an accepted parcel stop still leaves Home for Bookings', ( + tester, + ) async { + // The handoff rule is untouched — only the *active* state moved. + ServiceProfile.setActive(ServiceProfile.parcel); + await pumpRow(tester, status: 'accepted', onContinue: (_) {}); + + expect(find.text('Customer 1'), findsNothing); + expect(find.text('Active'), findsNothing); + }); +} diff --git a/test/activity_format_test.dart b/test/activity_format_test.dart new file mode 100644 index 0000000..424d0f5 --- /dev/null +++ b/test/activity_format_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/activity/activity_format.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// READING A DOORMILE TIMESTAMP +/// +/// Doormile timestamps are IST wall-clock in Postgres `timestamp without time +/// zone` columns. Some responses come back with a trailing `Z` anyway — a Go + +/// pgx footgun where a naive DB timestamp loads into `time.Time` under the UTC +/// location and marshals with a zone marker it never had. The console strips it +/// the same way; see `src/utils/doormileTimestamp.js`. +/// +/// `DateTime.tryParse` believes the `Z`, and that broke the tracking rail in the +/// one way it cannot survive: on a real phone an event stamped `11:43Z` sorted +/// *below* two 11:57 events, because as an absolute instant it is 17:13 IST — +/// while its own rung still printed `11:43`, so nothing on screen explained the +/// order. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + group('parseStamp', () { + test('a false Z is stripped, not believed', () { + final withZ = parseStamp('2026-08-20T11:43:00.000Z')!; + final naive = parseStamp('2026-08-20T11:43:00.000')!; + + // Same wall-clock, and therefore the same instant on this device. + expect(withZ, naive); + expect(withZ.hour, 11); + expect(withZ.minute, 43); + expect(clockOf(withZ), '11:43 AM'); + }); + + test('an offset marker is stripped too', () { + expect( + parseStamp('2026-08-20T11:43:00+05:30'), + parseStamp('2026-08-20T11:43:00'), + ); + }); + + test('it sorts by the clock it prints', () { + // The exact shape of the device bug: a backend stamp and two local ones. + final placed = parseStamp('2026-08-20T11:43:00.000Z')!; + final setOff = parseStamp('2026-08-20T11:57:00.000')!; + expect(placed.isBefore(setOff), isTrue); + }); + + test('empty and unparseable are null, never a fabricated moment', () { + expect(parseStamp(null), isNull); + expect(parseStamp(''), isNull); + expect(parseStamp(' '), isNull); + expect(parseStamp('not a date'), isNull); + }); + }); + + group('happenedAt', () { + test('reads through parseStamp, so the list and the record agree', () { + final row = {'completedat': '2026-08-20T11:43:00.000Z'}; + expect(happenedAt(row), parseStamp('2026-08-20T11:43:00')!); + }); + + test('a record with no usable stamp sinks rather than jumping the day', () { + expect(happenedAt(const {}).millisecondsSinceEpoch, 0); + }); + }); + + group('routeLineOf', () { + test('reads as speech, and carries no arrow character', () { + // Poppins has no U+2192, so the app drew the most-read line on the record + // as `Vidhya Kitchen ▯ Joe Mathew`. The arrow is a Material glyph on + // screen now; this string is for screen readers and logs, where the word + // is what a listener needs anyway. + const stop = { + 'pickupcustomer': 'Joe Mathew', + 'sourcename': 'Vidhya Kitchen', + }; + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + expect(routeLineOf(stop), 'Vidhya Kitchen to Joe Mathew'); + expect(routeLineOf(stop), isNot(contains('→'))); + }); + }); +} diff --git a/test/activity_layout_test.dart b/test/activity_layout_test.dart new file mode 100644 index 0000000..6e8f7a0 --- /dev/null +++ b/test/activity_layout_test.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/activity/activity_page.dart'; +import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; +import 'package:miler/data/accepted_store.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ACTIVITY AND THE RECORD, ACROSS THE PHONES THIS SHIPS TO +/// +/// `trip_brief_layout_test` does this for Home and is the reason Home's +/// overflows get caught; the history half of the app had no equivalent, so a +/// row that fits a 390pt reference device was the only thing anyone checked. +/// +/// Both screens changed in the same pass — the Activity row lost its footer +/// band and gained the reference on its measurements line, and the record's +/// Route section now hangs an address on *each* end — and both changes put more +/// on a line rather than less. That is exactly the shape of edit that overflows +/// a narrow phone, so it is the shape that needs a sweep. +/// +/// One assertion only: **nothing throws.** A `RenderFlex` overflow is an +/// exception in a test binding, and this is the cheapest honest way to ask +/// "does it fit?" across a matrix without pinning pixel values that legitimate +/// design changes would have to churn. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + /// The four the brief names. 320 is the narrowest Android still in the + /// field; 412 is a Pixel-class device and was in none of the app's existing + /// sweeps. + const widths = [320.0, 375.0, 390.0, 412.0]; + + /// 2.0 is the top of Android's font-size slider. + const textScales = [1.0, 1.3, 2.0]; + + final today = DateTime.now(); + final at = DateTime(today.year, today.month, today.day, 11, 42); + + /// A worst case on purpose: a long customer name, a long kitchen name, a + /// 21-character booking reference and a cash figure — every field that + /// competes for the same line, all at once. + Map record(String id) => { + 'orderid': id, + 'bookingno': id, + 'type': 'delivery', + 'pickupcustomer': 'Venkataraman Subramanian', + 'name': 'Venkataraman Subramanian', + 'sourcename': 'DailyGrubs RS Puram Central Kitchen', + 'kitchenname': 'DailyGrubs RS Puram Central Kitchen', + 'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002', + 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'deliveryaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'orderstatus': 'delivered', + 'completedday': + '${at.year}-${at.month.toString().padLeft(2, '0')}-' + '${at.day.toString().padLeft(2, '0')}', + 'completedat': at.toIso8601String(), + 'startedat': at.subtract(const Duration(minutes: 87)).toIso8601String(), + 'arrivedat': at.subtract(const Duration(minutes: 5)).toIso8601String(), + 'kms': '14.6', + 'collectionamt': '123456', + 'proof': {'collected': 2, 'amountcollected': 123456.0}, + }; + + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Future sized( + WidgetTester tester, + double width, + double scale, + Widget child, { + double height = 844, + }) async { + tester.view.physicalSize = Size(width * 3, height * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: child, + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 2)); + } + + for (final width in widths) { + for (final scale in textScales) { + testWidgets('the activity list fits ${width.toInt()}pt / ${scale}x', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({'userId': 1, 'userid': 1}); + await debugSeedStore( + 'completed_bookings', + jsonEncode([ + record('DM-BK-16DA518D-6381'), + record('DM-BK-16DA518D-6382'), + ]), + ); + + await sized(tester, width, scale, const ActivityPage()); + + expect( + tester.takeException(), + isNull, + reason: 'the activity list overflowed at ${width}pt / ${scale}x', + ); + }); + + testWidgets('the delivery record fits ${width.toInt()}pt / ${scale}x', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({'userId': 1, 'userid': 1}); + + // Tall, because the record is several screens and its `ListView` is + // lazy — a short viewport never builds the sections being swept. + await sized( + tester, + width, + scale, + DeliveryDetailsPage(stop: record('DM-BK-16DA518D-6381')), + height: 2600, + ); + + expect( + tester.takeException(), + isNull, + reason: 'the delivery record overflowed at ${width}pt / ${scale}x', + ); + }); + } + } +} diff --git a/test/activity_list_test.dart b/test/activity_list_test.dart index 0547c9d..c47f3c8 100644 --- a/test/activity_list_test.dart +++ b/test/activity_list_test.dart @@ -3,12 +3,14 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/views/Dashboard/activity/activity_page.dart'; -import 'package:miler/views/Dashboard/home/homepage.dart'; import 'package:miler/views/Dashboard/profile/Profilepage.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; +import 'package:miler/data/accepted_store.dart'; /// ── A day's work is a list, not a stack of cards ── /// @@ -50,9 +52,10 @@ void main() { } Future pumpActivity(WidgetTester tester) async { - SharedPreferences.setMockInitialValues({ - 'userId': 1, - 'completed_bookings': jsonEncode([ + SharedPreferences.setMockInitialValues({'userId': 1}); + await debugSeedStore( + 'completed_bookings', + jsonEncode([ finished( 'DM240817', 'Sundar Textiles', @@ -69,7 +72,7 @@ void main() { minsAgo: 68, ), ]), - }); + ); tester.view.physicalSize = const Size(390 * 3, 844 * 3); tester.view.devicePixelRatio = 3.0; @@ -102,64 +105,43 @@ void main() { expect(fills, isNot(contains(ColorConstants.cardSurface))); }); - testWidgets('the figures line up across rows, not just within one', ( - tester, - ) async { + // ── A history row says who, when and how much, and nothing else ── + // + // Every finished stop used to carry a two-line address and a verdict + // sentence — `Pickup & Delivery · On time · +2.1 km over · ₹2,450` — so a + // rider looking for one drop read four to six lines per row to find it. + // + // The type and the compliance figures did not disappear; they are fields in + // the row's own detail panel, which is where a rider goes when he wants them + // for ONE stop rather than for all forty. + testWidgets('the collapsed row carries no verdict sentence', (tester) async { await pumpActivity(tester); - // The point of three fixed columns is comparing one stop against the next. - // While every row sat in its own padded card that only held *inside* a row; - // sharing the page margin is what makes column two of one stop sit directly - // above column two of the one below. - final captions = tester.widgetList(find.text('COLLECTED')).toList(); - expect(captions.length, 2); + // The subject of the row. + expect(find.text('Sundar Textiles'), findsOneWidget); - final rects = [ - for (var i = 0; i < 2; i++) tester.getRect(find.text('COLLECTED').at(i)), - ]; - expect(rects[0].left, closeTo(rects[1].left, 0.5)); + // `On time` on every row separates nothing. Only exceptions get words now. + expect(find.text('On time'), findsNothing); + expect(find.text('Time not measured'), findsNothing); - // Column one starts flush with the stop name above it — no card inset. - final time = tester.getRect(find.text('TIME').first); - final name = tester.getRect(find.text('Sundar Textiles')); - expect(time.left, closeTo(name.left, 0.5)); - }); - - testWidgets('the stop type is a dotted word, not a filled tag', ( - tester, - ) async { - await pumpActivity(tester); - - // Two tinted rectangles under every address — the type and the order id — - // were the loudest things on a row whose subject is the figures below them. - expect(find.text('Pickup & Delivery'), findsOneWidget); - // `P&D` is `chipLabel`, which only ever reached this page through the - // filled tag. (`badgeLabel` still appears inside the collapsed detail, - // which `AnimatedCrossFade` builds either way — that one is a field value, - // not a mark.) + // `titleLabel`, the sentence-case type word, was the verdict line's lead + // and is gone with it. `badgeLabel` is a field value in the detail panel, + // which `AnimatedCrossFade` builds whether or not the row is open. + expect(find.text('Pickup & Delivery'), findsNothing); expect(find.text('P&D'), findsNothing); + // The type is the record's business, one tap away — see + // `test/delivery_details_test.dart`. + expect(find.textContaining('PICKUP & DELIVERY'), findsNothing); - final label = tester.widget(find.text('Pickup & Delivery')); - expect(label.style!.color, ColorConstants.combinedAccent); - - // The reference stays copyable, in grey, without claiming any rank. - final id = tester.widget(find.text('#DM240817')); - expect(id.style!.color, ColorConstants.secondaryText); - }); - - testWidgets('column captions are readable, not disabled-grey', ( - tester, - ) async { - await pumpActivity(tester); - - // #C7C7CC is a disabled-control colour. It measured about 1.9:1 on the - // white page, which made the three words naming the columns the least - // readable text on a screen used outdoors. - for (final caption in const ['TIME', 'RIDDEN', 'COLLECTED']) { - final text = tester.widget(find.text(caption).first); - expect(text.style!.color, ColorConstants.secondaryText); - expect(text.style!.color, isNot(ColorConstants.disabledFill)); - } + // ── The reference left the row ── + // + // It used to head the card, on the argument that an office reads it down + // a phone at a rider. That is true and it is still the wrong place for it: + // the office asks about *one* stop, and the rider finds that stop by the + // customer's name — which he then taps. Printing a database identifier on + // every row of a shift log makes him scan past twelve of them to reach the + // one name he is looking for. It lives on the record page. + expect(find.text('#DM240817'), findsNothing); }); testWidgets('the four tabs agree on what colour the paper is', ( @@ -168,16 +150,22 @@ void main() { // Account was on `surface` (#FCF9F8, a warm off-white) while Home and // Activity were on `pureSurface`. Bookings hides its background under // cards; Account is bare rows, so the whole tab read a shade dirtier. - Color? bgOf(Widget page) { - final scaffolds = []; + // + // The paper moved: the scaffold's own background is the **brand** now, so + // it shows through the two rounded top corners of [MilerSheet], and the + // sheet paints the page. Both halves are contracts — a tab whose ground is + // white draws no corners at all, and a tab whose sheet is off-white is the + // original bug one layer down — so both are asserted. + T? firstOf() { + final found = []; void visit(Element e) { final w = e.widget; - if (w is Scaffold) scaffolds.add(w); + if (w is T) found.add(w); e.visitChildren(visit); } WidgetsBinding.instance.rootElement!.visitChildren(visit); - return scaffolds.isEmpty ? null : scaffolds.first.backgroundColor; + return found.isEmpty ? null : found.first; } SharedPreferences.setMockInitialValues({'userId': 1}); @@ -193,7 +181,67 @@ void main() { ), ); await tester.pump(); - expect(bgOf(page), ColorConstants.pureSurface); + + expect( + firstOf()?.backgroundColor, + ColorConstants.primary, + reason: 'the ground behind the sheet corners must be the brand', + ); + final sheet = firstOf(); + expect(sheet, isNotNull, reason: 'every tab sits in a MilerSheet'); + // What the tabs must agree on is the *ladder*, not one flat paper: + // a sheet is either the working white (rows drawn straight on it — + // Activity) or the canvas with white panels standing on it (Home, + // Account). What stays banned is what this test was written against — + // the warm off-white that made one tab read a shade dirtier than the + // rest. + expect( + sheet!.color ?? ColorConstants.pureSurface, + anyOf(ColorConstants.pureSurface, ColorConstants.daylightSurface), + reason: 'a sheet is a rung of the surface ladder, never an off-white', + ); } }); + + // ── A row offers one gesture, and it is the whole row ── + // + // The card carried a footer band: a hairline, ~22pt of gaps, the booking + // reference, and a red bold `Details ›` — which had **no `onTap`**. The + // GlassCard around it was the gesture and always had been, so the loudest + // element on a finished stop was a link that could not be pressed, pointing + // at the tap the rider was already making. It cost ~41pt on every row of a + // page that exists to be scanned. + // + // The reference stayed — the office recites it down a phone at him — and + // moved onto the right edge of the measurements line, which was empty. + testWidgets('a finished row has no second way in', (tester) async { + await pumpActivity(tester); + + expect( + find.text('Details'), + findsNothing, + reason: 'a control that cannot be pressed is worse than no control', + ); + // The chevron RETURNED, deliberately, after the Home disclosure pass + // taught the difference: the sin was a bold red "Details ›" styled as a + // second button; a quiet border-grey chevron on the name line is the + // opposite thing — the passive "this goes somewhere" signal every + // Account row wears, marking the gesture the whole card owns. + expect( + find.byIcon(LucideIcons.chevronRight), + findsWidgets, + reason: 'a tappable row without any affordance is a secret', + ); + + // The reference is on the record page now, not the row — see above. + expect(find.text('#DM240817'), findsNothing); + }); + + // ── `the reference shares the measurements line` is gone ── + // + // It pinned the reference and the duration onto one storey, which was the + // right fix while the reference was on the row at all. It is not on the row + // any more, so the assertion has no subject — and a test kept alive by + // rewriting it to check something it was never about is worse than one + // deleted with its reasoning recorded. } diff --git a/test/activity_page_test.dart b/test/activity_page_test.dart new file mode 100644 index 0000000..4d0ebf7 --- /dev/null +++ b/test/activity_page_test.dart @@ -0,0 +1,543 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/activity/activity_page.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/data/accepted_store.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ACTIVITY, AS A DELIVERY HISTORY +/// +/// The page used to be a day's worth of rows headed by the customer's name — +/// the one fact on a finished stop a rider cannot act on, and the one he is not +/// scanning for. History is opened to answer "did that go through?", and the +/// answer to that is a word. +/// +/// So the row leads with the outcome, identifies itself with the route +/// underneath, and carries its measurements as one line of running text. Above +/// the list: what this screen is, and which slice of it. These hold that shape. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + String day(DateTime t) => + '${t.year}-${t.month.toString().padLeft(2, '0')}-' + '${t.day.toString().padLeft(2, '0')}'; + + final now = DateTime.now(); + + Map done( + String id, + String who, { + int minsAgo = 20, + String status = 'delivered', + double cash = 0, + }) { + final at = now.subtract(Duration(minutes: minsAgo)); + return { + 'orderid': id, + 'type': 'delivery', + 'pickupcustomer': who, + 'sourcename': 'Vidhya Kitchen', + 'pickupaddress': '78, Cross Cut Road, Coimbatore', + 'orderstatus': status, + 'completedday': day(now), + 'completedat': at.toIso8601String(), + 'startedat': at.subtract(const Duration(minutes: 28)).toIso8601String(), + 'compliance': const {'ontime': true, 'actualkm': 3.2, 'plannedkm': 3.0}, + if (cash > 0) 'collectionamt': '$cash', + 'proof': {'collected': 2, if (cash > 0) 'amountcollected': cash}, + }; + } + + Map parked(String id, String who, String reason) => { + 'orderid': id, + 'type': 'delivery', + 'pickupcustomer': who, + 'sourcename': 'Vidhya Kitchen', + 'pickupaddress': '19, Thadagam Road, Coimbatore', + 'orderstatus': 'skipped', + 'skipreason': reason, + 'skippedday': day(now), + 'skippedat': now.subtract(const Duration(minutes: 90)).toIso8601String(), + }; + + Future pump( + WidgetTester tester, { + List> completed = const [], + List> skipped = const [], + bool settle = true, + }) async { + SharedPreferences.setMockInitialValues({'userId': 1}); + await debugSeedStore('completed_bookings', jsonEncode(completed)); + await debugSeedStore('skipped_bookings', jsonEncode(skipped)); + // A phone's shape, and taller than one. The rows are cards now, so a + // default 800×600 harness cuts the list off part-way and a lazy `ListView` + // never builds what is past the fold — every assertion about a third row + // would then pass or fail on viewport height rather than on the widget. + tester.view.physicalSize = const Size(390 * 3, 1400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: ActivityPage()), + ), + ); + if (settle) { + await tester.pumpAndSettle(const Duration(seconds: 2)); + } else { + await tester.pump(); + } + } + + /// Taps a filter chip, scrolling the bar to it first. + /// + /// The bar scrolls on purpose — four chips do not fit a narrow phone at large + /// text scales, and a bar that reflowed to two lines would move the list under + /// the rider's thumb. A chip past the fold is built but outside the viewport, + /// and a tap at its centre lands on whatever is painted there instead. + Future selectFilter(WidgetTester tester, String label) async { + await tester.scrollUntilVisible( + find.text(label), + 80, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + } + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the chrome', () { + testWidgets('the header answers how the shift is going', (tester) async { + await pump( + tester, + completed: [done('A1', 'Joe Mathew'), done('A2', 'Sarah')], + ); + + // The strapline said "Your recent delivery activity" under an app bar + // reading **Activity** — one line repeating the other. What a rider + // opens this screen for between stops is how his day is going, so that + // is what the space says now. + expect(find.text('Your recent delivery activity'), findsNothing); + // The header is a figure now: the completed count as a numeral with + // "delivered today" as its caption — the same treatment Home's head + // uses, and the fixture's two finished stops are the number it shows. + expect(find.text('2'), findsOneWidget); + expect(find.textContaining('today'), findsOneWidget); + + for (final f in const ['All', 'Active', 'Completed', 'Cancelled']) { + expect(find.text(f), findsOneWidget, reason: '$f is missing'); + } + }); + + testWidgets('the selected filter is a rule, not a chip', (tester) async { + await pump(tester, completed: [done('A1', 'Joe Mathew')]); + + // ── This assertion moved down a level, deliberately ── + // + // It used to require exactly one *filled chip*, which was already the + // quiet version: a bar where all four are outlined is four boxes + // arguing about which one is on. But a filled pill is still a container, + // and on a page whose brief is that containers recede it was the + // loudest object above the list. + // + // What survives is the principle, one step further: the selected state + // is carried by the brand colour on the word plus a 2pt rule under it, + // and the unselected ones carry nothing at all. + final selected = tester.widget(find.text('All')); + expect(selected.style!.color, ColorConstants.primary); + expect(selected.style!.fontWeight, FontWeight.w700); + + final other = tester.widget(find.text('Completed')); + expect(other.style!.color, ColorConstants.secondaryText); + + // One rule, on the selected word only. + final rules = tester + .widgetList(find.byType(AnimatedContainer)) + .map((c) => c.decoration) + .whereType() + .where((d) => d.color == ColorConstants.primary) + .toList(); + expect(rules.length, 1); + }); + + testWidgets('there is no chrome to operate on an empty day', ( + tester, + ) async { + await pump(tester); + + // Nothing to filter, so the empty state owns the screen rather than + // sitting under a bar the rider cannot usefully press. + expect(find.text('Your recent delivery activity'), findsNothing); + expect(find.text('Completed'), findsNothing); + expect(find.textContaining('Go to'), findsOneWidget); + }); + }); + + group('the row', () { + testWidgets('leads with the outcome, in the outcome\'s colour', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, completed: [done('A1', 'Joe Mathew')]); + + // The outcome word goes quiet for the norm: the rail disc beside the + // row already carries "delivered" in shape and colour, and a day of 25 + // green words left no colour for the row that went wrong. The word only + // wears the status ink when the status is the exception — pinned in + // activity_row_test's parked-stop case. + final word = tester.widget(find.textContaining('Delivered')); + expect(word.style!.color, ColorConstants.secondaryText); + + // The rider knows a stop by who was at the door, so the name leads. + final name = tester.widget(find.text('Joe Mathew')); + expect(name.style!.fontWeight, FontWeight.w600); + expect( + name.style!.fontSize, + greaterThan(word.style!.fontSize!), + reason: 'the customer outranks the status word', + ); + + // Where it came from is the supporting line under it — one line now, + // both ends together, rather than the source as a separate fragment. + expect(find.textContaining('Vidhya Kitchen'), findsOneWidget); + + // ── The booking reference is gone from the row ── + // + // It was a footer here, which is already quiet, but quiet is not the + // question: it is a lookup key an office reads down a phone, and a + // rider scanning his own shift should not be passing over database + // identifiers to reach a name. It is on the record page, one tap away. + expect(find.text('#A1'), findsNothing); + + // The measurements are running text, not columns and not chips. + expect(find.text('3.2 km'), findsOneWidget); + expect(find.text('28m'), findsOneWidget); + + // The address is detail for one stop; it belongs in the record. + expect(find.text('78, Cross Cut Road, Coimbatore'), findsNothing); + }); + + testWidgets('a collection with no source names one end only', ( + tester, + ) async { + // A parcel booking is a first-mile collection *from* the customer, and + // where it goes next is the hub's problem — there is no second name to + // print, and inventing an arrow to nowhere would read as a route. + await pump( + tester, + completed: [done('A1', 'Sundar Textiles', status: 'picked')], + ); + // The customer IS the collection point on this line, so the name leads + // and there is no second end to name under it. + expect(find.text('Sundar Textiles'), findsOneWidget); + expect(find.byIcon(LucideIcons.store), findsNothing); + }); + }); + + group('the slices', () { + testWidgets('Completed drops the parked work', (tester) async { + await pump( + tester, + completed: [done('A1', 'Joe Mathew', status: 'picked')], + skipped: [parked('A9', 'Sri Balaji Stores', 'Shutter closed')], + ); + + expect(find.textContaining('Skipped'), findsOneWidget); + await selectFilter(tester, 'Completed'); + + expect(find.textContaining('Skipped'), findsNothing); + expect(find.textContaining('Picked up'), findsOneWidget); + // The lifted band is All's alone: a list of nothing but parked work does + // not need a heading saying so. + expect(find.text('NEEDS A RETURN VISIT'), findsNothing); + }); + + testWidgets('an empty slice says so about itself, with a way back', ( + tester, + ) async { + await pump( + tester, + completed: [done('A1', 'Joe Mathew', status: 'picked')], + ); + + await selectFilter(tester, 'Cancelled'); + + // A fact about the slice, never an apology — and never the day's own + // empty state, which would tell the rider he had done nothing. + // + // `Show all` is the slice's own way out; the day's empty state offers + // `Go to Bookings` instead. Asserting on the slice's copy is the + // unambiguous half — both states carry *a* button, and which one is + // showing is what this test is about. + expect(find.text('Nothing was cancelled.'), findsOneWidget); + expect(find.text('Show all'), findsOneWidget); + + await tester.tap(find.widgetWithText(MilerButton, 'Show all')); + await tester.pumpAndSettle(); + expect(find.textContaining('Picked up'), findsOneWidget); + }); + }); + + group('grouping — the rider’s trips, not the clock’s bands', () { + /// A stop finished inside a given hour today, so the day-part rule places + /// it in a known trip. See [DayPart]. + Map atHour(String id, String who, int hour) { + final at = DateTime(now.year, now.month, now.day, hour, 15); + return { + ...done(id, who), + 'completedat': at.toIso8601String(), + 'expected_pickup_time': at.toIso8601String(), + }; + } + + testWidgets('one run needs no accordion — it opens as itself', ( + tester, + ) async { + await pump( + tester, + completed: [ + atHour('A1', 'Joe Mathew', 9), + atHour('A2', 'Green Leaf', 10), + ], + ); + + // A single header the rider must tap to see the only thing on the page + // is a lock on an empty door. + expect(find.text('Trip 1'), findsOneWidget); + expect(find.text('Joe Mathew'), findsOneWidget); + expect(find.text('Green Leaf'), findsOneWidget); + expect(find.byIcon(LucideIcons.chevronDown), findsNothing); + }); + + testWidgets('three runs become three headers, all closed', (tester) async { + await pump( + tester, + completed: [ + atHour('A1', 'Joe Mathew', 9), + atHour('A2', 'Green Leaf', 14), + atHour('A3', 'Sri Balaji', 19), + ], + ); + + expect(find.text('Trip 1'), findsOneWidget); + expect(find.text('Trip 2'), findsOneWidget); + expect(find.text('Trip 3'), findsOneWidget); + // Collapsed: the summary answers "did that run go well" without opening. + expect(find.text('Joe Mathew'), findsNothing); + expect(find.byIcon(LucideIcons.chevronDown), findsNWidgets(3)); + }); + + testWidgets('opening one trip closes the one already open', (tester) async { + await pump( + tester, + completed: [ + atHour('A1', 'Joe Mathew', 9), + atHour('A2', 'Green Leaf', 14), + ], + ); + + await tester.tap(find.text('Trip 1')); + await tester.pumpAndSettle(); + expect(find.text('Joe Mathew'), findsOneWidget); + expect(find.text('Green Leaf'), findsNothing); + + await tester.tap(find.text('Trip 2')); + await tester.pumpAndSettle(); + expect( + find.text('Joe Mathew'), + findsNothing, + reason: + 'three expanded bodies is the list the rider already could ' + 'not read — one at a time is the whole point', + ); + expect(find.text('Green Leaf'), findsOneWidget); + + // Tapping the open one closes it again. + await tester.tap(find.text('Trip 2')); + await tester.pumpAndSettle(); + expect(find.text('Green Leaf'), findsNothing); + }); + + testWidgets('a skipped stop stays inside the trip it belonged to', ( + tester, + ) async { + final skip = { + ...parked('A9', 'Sri Balaji Stores', 'Shutter closed'), + 'skippedat': DateTime( + now.year, + now.month, + now.day, + 9, + 30, + ).toIso8601String(), + 'expected_pickup_time': DateTime( + now.year, + now.month, + now.day, + 9, + 30, + ).toIso8601String(), + }; + await pump( + tester, + completed: [atHour('A1', 'Joe Mathew', 9)], + skipped: [skip], + ); + + // One morning run holding both outcomes. The summary names them apart + // without opening, and neither word is an API enum. + expect(find.text('Trip 1'), findsOneWidget); + expect(find.textContaining('skipped'), findsWidgets); + expect(find.text('Sri Balaji Stores'), findsOneWidget); + expect(find.text('Joe Mathew'), findsOneWidget); + }); + + testWidgets('no date band, and never the word UNDATED', (tester) async { + // A record whose timestamps will not parse is a payload defect, not a + // fact about the rider's morning — and not something to print at him. + await pump( + tester, + completed: [ + {...done('A1', 'Joe Mathew'), 'completedat': 'not-a-timestamp'}, + ], + ); + + expect(find.text('UNDATED'), findsNothing); + expect(find.text('Undated'), findsNothing); + expect(find.text('TODAY'), findsNothing); + expect( + find.text('Joe Mathew'), + findsOneWidget, + reason: + 'the record is kept, quietly — dropping it would lose a ' + "rider's completed stop over a missing field", + ); + }); + + testWidgets('"Trip 2 had a problem", then "Arun is the one"', ( + tester, + ) async { + // ── The whole brief, as one test ── + // + // At arm's length, in two seconds: which trip went wrong. Then, one tap + // later: which stop is owed. If either answer needs reading rather than + // glancing, the page has failed regardless of how it photographs. + final skipAt = DateTime(now.year, now.month, now.day, 14, 40); + await pump( + tester, + completed: [ + atHour('A1', 'Joe Mathew', 9), + atHour('A2', 'Sarah', 10), + atHour('A3', 'Priya Nair', 14), + atHour('A4', 'Meera', 19), + ], + skipped: [ + { + ...parked('A5', 'Arun', 'Customer unavailable'), + 'skippedat': skipAt.toIso8601String(), + 'expected_pickup_time': skipAt.toIso8601String(), + }, + ], + ); + + // 1. The shift says something is owed, before any trip is opened. + expect(find.textContaining('needs attention'), findsOneWidget); + + // 2. And exactly one trip admits to it — Trip 2, on its collapsed + // header, without the rider opening anything. + expect(find.text('Trip 1'), findsOneWidget); + expect(find.text('Trip 2'), findsOneWidget); + expect(find.text('Trip 3'), findsOneWidget); + expect(find.textContaining('skipped'), findsOneWidget); + expect(find.text('Arun'), findsNothing, reason: 'still collapsed'); + + // 3. Opening it names the stop and offers the one thing left to do. + await tester.tap(find.text('Trip 2')); + await tester.pumpAndSettle(); + expect(find.text('Arun'), findsOneWidget); + expect(find.text('Customer unavailable'), findsOneWidget); + expect(find.text('Resume stop'), findsOneWidget); + + // 4. And the settled stops in the same trip carry no action at all. + expect(find.text('Priya Nair'), findsOneWidget); + expect( + find.text('Resume stop'), + findsOneWidget, + reason: 'a completed stop has nothing left to do', + ); + }); + + testWidgets('a completed row carries no booking reference', (tester) async { + // The reference is a lookup key for an office. It lives on the record + // page; a rider scanning his shift should not be reading past database + // identifiers to find a name. + await pump( + tester, + completed: [atHour('DM-BK-501CB551', 'Joe Mathew', 9)], + ); + expect(find.text('Joe Mathew'), findsOneWidget); + expect(find.textContaining('DM-BK-501CB551'), findsNothing); + }); + + testWidgets('no internal line or type name reaches the page', ( + tester, + ) async { + await pump(tester, completed: [atHour('A1', 'Joe Mathew', 9)]); + for (final leak in const ['MILK', 'Milk', 'Milk Man', 'milkMan']) { + expect(find.text(leak), findsNothing, reason: leak); + } + }); + }); + + group('states', () { + testWidgets('the skeleton is drawn, not just laid out', (tester) async { + await pump(tester, settle: false); + + // `SkeletonBone` paints in `pureSurface` — the page's own colour — and is + // visible only inside the shimmer that recolours its subtree. Without the + // wrapper this state rendered as a blank white screen, which is + // indistinguishable from a load that has hung. + expect(find.byType(MilerShimmer), findsOneWidget); + expect( + find.descendant( + of: find.byType(MilerShimmer), + matching: find.byType(SkeletonBone), + ), + findsWidgets, + ); + }); + + testWidgets('the empty state is artwork and one way out', (tester) async { + await pump(tester); + + // ── The words went; the picture carries it ── + // + // A glyph, a headline and a sentence under it were three statements of + // one fact — nothing finished yet — on a screen whose emptiness already + // says so. Product's call is that the artwork says it alone. + expect(find.text('No activity yet'), findsNothing); + expect( + find.text('Stops you finish today will appear here.'), + findsNothing, + ); + expect(find.byIcon(LucideIcons.history), findsNothing); + + // What remains is the one thing here the rider can act on. + // + // The artwork itself is asserted by the reference shot rather than by a + // finder: `Image.asset` resolves against a bundle the test binding does + // not carry, so a finder for it would be pinning the harness rather than + // the screen. + expect(find.textContaining('Go to'), findsOneWidget); + }); + }); +} diff --git a/test/activity_refresh_test.dart b/test/activity_refresh_test.dart index 5c6cb66..c69197f 100644 --- a/test/activity_refresh_test.dart +++ b/test/activity_refresh_test.dart @@ -13,10 +13,12 @@ void main() { testWidgets('an IndexedStack builds every child up front', (tester) async { final built = []; - Widget page(int i) => Builder(builder: (_) { - built.add(i); - return SizedBox(key: ValueKey('page$i')); - }); + Widget page(int i) => Builder( + builder: (_) { + built.add(i); + return SizedBox(key: ValueKey('page$i')); + }, + ); await tester.pumpWidget( MaterialApp( diff --git a/test/activity_row_test.dart b/test/activity_row_test.dart new file mode 100644 index 0000000..d01b78b --- /dev/null +++ b/test/activity_row_test.dart @@ -0,0 +1,399 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/activity/activity_page.dart'; +import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/data/accepted_store.dart'; + +/// ── What a finished row says, and what it stopped saying ── +/// +/// A finished stop used to carry, under its address, a filled state tag, a +/// dotted type, a copyable reference and three 17pt figures with a coloured +/// verdict under each — six marks per row on a tab whose job is to be scanned. +/// A day's work was taller than the day's work list on the Deliveries tab. +/// +/// It is three lines now: who and when, where, and one verdict line that says +/// what kind of stop it was and how it went. The record — every stamp, every +/// figure, the reference, the money — is one tap under it. +/// +/// [activity_list_test.dart] holds the older half of this contract: that a row +/// is not a card, that the figure columns line up across rows rather than only +/// within one, that the type is a word rather than a filled tag. This file +/// holds what the redesign added: what is on the collapsed row, that a missing +/// measurement is said rather than invented, and that money is gated on the +/// capability rather than on the line of work. +void main() { + String day(DateTime t) => + '${t.year}-${t.month.toString().padLeft(2, '0')}-' + '${t.day.toString().padLeft(2, '0')}'; + + final now = DateTime.now(); + + Map stop( + String id, + String name, { + required String status, + String type = 'delivery', + int minsAgo = 20, + double cash = 0, + double? collected, + Map? compliance, + }) { + final at = now.subtract(Duration(minutes: minsAgo)); + return { + 'orderid': id, + 'tripid': 'T1', + 'type': type, + 'pickupcustomer': name, + 'pickupaddress': '12, SNS Colony, Coimbatore', + 'orderstatus': status, + 'completedday': day(now), + 'completedat': at.toIso8601String(), + 'startedat': at.subtract(const Duration(minutes: 14)).toIso8601String(), + 'arrivedat': at.subtract(const Duration(minutes: 3)).toIso8601String(), + if (cash > 0) 'collectionamt': '$cash', + if (compliance != null) 'compliance': compliance, + 'proof': { + if (collected != null) 'amountcollected': collected, + 'weight': '2.4', + 'collected': 2, + }, + }; + } + + Map parked(String id, String name, String reason) => { + 'orderid': id, + 'tripid': 'T1', + 'type': 'delivery', + 'pickupcustomer': name, + 'pickupaddress': '19, Thadagam Road, Coimbatore', + 'orderstatus': 'skipped', + 'skipreason': reason, + 'skippedday': day(now), + 'skippedat': now.subtract(const Duration(minutes: 40)).toIso8601String(), + }; + + Future pump( + WidgetTester tester, { + List> done = const [], + List> skipped = const [], + ServiceProfile profile = ServiceProfile.parcel, + bool settle = true, + }) async { + ServiceProfile.setActive(profile); + SharedPreferences.setMockInitialValues({'userId': 1}); + await debugSeedStore('completed_bookings', jsonEncode(done)); + await debugSeedStore('skipped_bookings', jsonEncode(skipped)); + + tester.view.physicalSize = const Size(390 * 3, 1600 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: ActivityPage()), + ), + ); + if (settle) { + await tester.pumpAndSettle(const Duration(seconds: 2)); + } else { + // The empty state runs a looping Lottie, so nothing ever settles. + await tester.pump(); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 80)); + } + } + } + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + final onTime = {'ontime': true, 'actualkm': 3.4, 'plannedkm': 3.1}; + final late7 = { + 'ontime': false, + 'latebyseconds': 420, + 'actualkm': 6.2, + 'plannedkm': 3.0, + }; + + // ── History is scanned, not read ── + // + // The collapsed row carried a name, a two-line address and a verdict sentence + // (`Delivery · On time · +3.2 km over · ₹340`) — four to six lines per stop, + // on a page whose question is "what did I do today". A rider looking for one + // drop read forty lines to find it. + // + // What is left collapsed: who, when, how much, and a mark for anything that + // did not go to plan. The rest did not disappear — it is in the row's own + // detail panel, one tap away, which is where a rider wants it for ONE stop. + /// Opens a history row. The detail panel is built only while the row is open + /// — `AnimatedSize` around one child rather than a crossfade laying out both, + /// which is what removed the overflow stripe on collapse. + /// Opens a stop's record, and unfolds the groups the measurements live in. + /// + /// The record page ranks its facts now: outcome, journey and proof lead, and + /// the ledger — distance, ETA, payment, reference — sits behind three folded + /// rows, tapped only when somebody in an office asks. The facts are + /// unchanged; reaching them costs a tap, so this takes it. + Future open(WidgetTester tester, String name) async { + await tester.tap(find.text(name)); + await tester.pumpAndSettle(); + + for (final group in const [ + 'Delivery details', + 'Payment', + 'Order details', + ]) { + // Inside its own control: the app bar's title is also "Delivery + // details", and an ambiguous finder makes `tap` throw. + final row = find.descendant( + of: find.byType(InkWell), + matching: find.text(group), + ); + if (row.evaluate().isEmpty) continue; + await tester.tap(row.first); + await tester.pumpAndSettle(); + } + } + + testWidgets('the collapsed row is a name, a clock and nothing else', ( + tester, + ) async { + await pump( + tester, + done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)], + ); + + expect(find.text('Joe Mathew'), findsOneWidget); + + // The address is detail for one stop, not a header for forty. + expect(find.text('12, SNS Colony, Coimbatore'), findsNothing); + // `titleLabel` led the verdict sentence and went with it. + expect(find.text('Delivery'), findsNothing); + // A norm stated on every row separates nothing. + expect(find.text('On time'), findsNothing); + + // The filled state tag is still gone. + expect(find.byType(OrderStatusTag), findsNothing); + }); + + testWidgets('an exception is coloured; the norm is not', (tester) async { + await pump( + tester, + done: [ + stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime), + stop( + 'A2', + 'Green Leaf', + status: 'picked', + minsAgo: 60, + compliance: late7, + ), + ], + ); + + // The principle is unchanged and is now absolute: the norm says NOTHING on + // the row, and the exception is the only thing that gets a word — in the + // warning colour, in a capsule, beside the stop it happened to. + expect(find.text('On time'), findsNothing); + expect( + tester.widget(find.text('Late 7m')).style!.color, + ColorConstants.warning, + ); + + // Being on route is the norm and is not said at all. Riding past it is — + // in the detail panel now, where the rest of the measurements live. + expect(find.text('On route'), findsNothing); + await open(tester, 'Green Leaf'); + expect(find.textContaining('3.2 km'), findsWidgets); + }); + + testWidgets('a stop nothing was measured on says so', (tester) async { + await pump(tester, done: [stop('A1', 'Anitha Ramesh', status: 'picked')]); + + // Said out loud, not implied by a missing row: `_detailRow` drops a null + // value, so silence here would be indistinguishable from a row that simply + // had not been rendered. + await open(tester, 'Anitha Ramesh'); + expect(find.text('Not measured'), findsWidgets); + + // A record that invents a plausible number is worse than one with a hole + // in it, because the hole can be investigated and the invention cannot be + // caught. None of these may appear in place of a missing measurement. + for (final invented in const ['0m', '0 min', '0 km', '0.0 km', '₹0']) { + expect(find.text(invented), findsNothing, reason: '"$invented" is a lie'); + } + }); + + // ── The record is a page, not an accordion ── + // + // It used to open under the row. An open record is several screens tall, so + // the row below it left the viewport — and left what a lazy `ListView` had + // built — and the list got harder to scan in exact proportion to how much it + // was used. Its own assertions moved with it to + // `test/delivery_details_test.dart`; what stays here is that the row is the + // way in and nothing more. + testWidgets('tapping a row opens the record on its own page', (tester) async { + await pump( + tester, + done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)], + ); + + // Nothing of the record is on the list, open or closed. + expect(find.text('ETA kept'), findsNothing); + expect(find.byType(DeliveryDetailsPage), findsNothing); + + await tester.tap(find.text('Joe Mathew')); + await tester.pumpAndSettle(); + + expect(find.byType(DeliveryDetailsPage), findsOneWidget); + // Twice: the app bar's title, and the first folded group under the + // journey. Both are legitimately called the same thing. + expect(find.text('Delivery details'), findsWidgets); + + // And the bar's own back control leaves the rider where he was in the day. + // `pageBack()` hunts for a Material/Cupertino back button; `MilerAppBar` + // draws its own, so the test presses what the rider presses. + await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); + await tester.pumpAndSettle(); + expect(find.byType(DeliveryDetailsPage), findsNothing); + expect(find.text('Joe Mathew'), findsOneWidget); + }); + + testWidgets('a meal run has no money anywhere on the page', (tester) async { + await pump( + tester, + profile: ServiceProfile.milkMan, + done: [ + // Cash on the payload, which a meal tenant must still never show: + // `collectsCash` is the one gate, and it is false here. + stop( + 'M1', + 'Priya Nair', + status: 'delivered', + cash: 220, + compliance: onTime, + ), + ], + ); + + await tester.tap(find.text('Priya Nair')); + await tester.pumpAndSettle(); + + expect(find.text('COLLECTED'), findsNothing); + expect(find.text('PAYMENT'), findsNothing); + expect(find.textContaining('₹'), findsNothing); + + // The shipment-desk fields and the record's own figures are the detail + // page's business — see `test/delivery_details_test.dart`. + expect(find.text('Weight'), findsNothing); + expect(find.text('Parcels handed over'), findsNothing); + }); + + testWidgets('an empty day is still something you can pull on', ( + tester, + ) async { + await pump(tester, settle: false); + + // ── The empty state is a caption, not a screen ── + // + // It was `nothing_finished_yet.png` at 300pt wide on a 390pt phone — a + // picture larger than anything this page draws when it *does* have + // something to say, standing in for the absence of six short rows. One + // glyph, one headline, one line and the way out. + expect( + find.byWidgetPredicate( + (w) => + w is Image && + w.image is AssetImage && + (w.image as AssetImage).assetName == + 'assets/images/nothing_finished_yet.png', + ), + findsNothing, + reason: 'the oversized artwork must not come back', + ); + // The empty state is the artwork plus the one control — see + // `activity_page_test`'s "artwork and one way out". + expect(find.textContaining('Go to'), findsOneWidget); + expect(find.textContaining('Go to'), findsOneWidget); + + // Pull-to-refresh is the rider's only way out of an empty day, and it dies + // the moment the empty state stops being scrollable — which is what a + // `Center` in place of this `ListView` would do. + final list = tester.widget( + find.byKey(const ValueKey('activity-empty')), + ); + expect(list.physics, isA()); + + await tester.fling( + find.byKey(const ValueKey('activity-empty')), + const Offset(0, 320), + 800, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(RefreshProgressIndicator), findsOneWidget); + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 80)); + } + }); + + testWidgets('a parked stop carries its reason and exactly one control', ( + tester, + ) async { + await pump( + tester, + done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)], + skipped: [ + parked('A9', 'Sri Balaji Stores', 'Customer unreachable'), + parked('A8', 'Kumaran Silks', 'Shutter closed'), + ], + ); + + // ── The lifted band is gone; the trip is the grouping now ── + // + // Parked work used to be pulled out of date order and led with, because in + // a single newest-first list a morning skip sank below everything finished + // since. The page is grouped by the rider's trips instead, so a skip sits + // in the run it happened on — which is where he looks for it — and the + // trip's own header counts it so a collapsed run still says one is owed. + // + // What must not change is the row itself: its reason, and one control. + expect(find.text('NEED A RETURN VISIT'), findsNothing); + + // The reason is the one thing on the row he cannot reconstruct for + // himself, and it is read in amber at the size the address is — not a + // 10.5sp filled pill. + expect(find.textContaining('Skipped'), findsNWidgets(2)); + + final reason = tester.widget(find.text('Customer unreachable')); + expect(reason.style!.color, ColorConstants.warning); + expect(reason.style!.fontSize, greaterThanOrEqualTo(12.5)); + + // ── One control per parked row, and it is compact now ── + // + // Still exactly one per parked row and none on a finished one: a row + // offering a second action makes the rider choose before he can act, and + // there is nothing else to do with a stop he has put down. + // + // What changed is its weight. A full-width filled button on every parked + // row made the exception look like the rule on a page that is otherwise + // pure history — so it is a compact inline control, at a real tap target, + // with the same in-flight guard the button had. + expect(find.text('Resume this stop'), findsNothing); + expect(find.text('Resume stop'), findsNWidgets(2)); + expect( + find.byType(MilerButton), + findsNothing, + reason: 'a history list is not a page of actions', + ); + }); +} diff --git a/test/activity_shots_test.dart b/test/activity_shots_test.dart new file mode 100644 index 0000000..a96bc62 --- /dev/null +++ b/test/activity_shots_test.dart @@ -0,0 +1,302 @@ +@Tags(['shots']) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/golden_fonts.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/activity/activity_page.dart'; +import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/data/accepted_store.dart'; + +/// Visual harness for the Activity tab. +/// +/// Unlike Home and Deliveries, [ActivityPage] *is* pumpable — it reads the +/// completed/skipped stores out of SharedPreferences, so a mocked store is a +/// whole rendered page rather than a component in a frame. Run with +/// `--update-goldens`. +void main() { + setUpAll(() async { + await loadGoldenFonts(); + }); + + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + String day(DateTime t) => + '${t.year}-${t.month.toString().padLeft(2, '0')}-' + '${t.day.toString().padLeft(2, '0')}'; + + // ── The clock has to be pinned, or the goldens rot every minute ── + // + // These records render absolute times ("5:21 PM"), so fixtures built off a + // bare `DateTime.now()` produce a different image on every run — the shots + // passed when generated and failed in the next full-suite pass. Today's + // *date* is kept so the page still groups them under TODAY / YESTERDAY; + // only the time of day is fixed. + final _today = DateTime.now(); + final now = DateTime(_today.year, _today.month, _today.day, 17, 30); + + Map finished( + String id, + String customer, + String source, { + required String type, + double cash = 0, + int minsAgo = 30, + int legMins = 14, + int daysAgo = 0, + }) { + final at = now + .subtract(Duration(days: daysAgo)) + .subtract(Duration(minutes: minsAgo)); + return { + 'orderid': id, + 'bookingno': id, + 'type': type, + 'pickupcustomer': customer, + 'name': customer, + 'sourcename': source, + 'kitchenname': source, + 'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002', + 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'deliveryaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'orderstatus': type == 'delivery' ? 'delivered' : 'picked', + 'completedday': day(now.subtract(Duration(days: daysAgo))), + 'completedat': at.toIso8601String(), + 'startedat': at.subtract(Duration(minutes: legMins)).toIso8601String(), + 'arrivedat': at.subtract(const Duration(minutes: 3)).toIso8601String(), + 'kms': '4.6', + if (cash > 0) 'collectionamt': '$cash', + 'proof': {if (cash > 0) 'amountcollected': cash}, + }; + } + + Future shoot( + WidgetTester tester, + String name, { + required List> completed, + List> skipped = const [], + ServiceProfile profile = ServiceProfile.milkMan, + double width = 390, + double height = 844, + double textScale = 1.0, + }) async { + ServiceProfile.setActive(profile); + SharedPreferences.setMockInitialValues({'userId': 1, 'userid': 1}); + // ── Both stores go through `debugSeedStore` ── + // + // The skipped set used to be planted as a bare `skipped_bookings` key in + // the mock prefs. Both stores are scoped per rider/tenant/line now, so a + // bare key is a key nothing reads — the fixture seeded a parked stop and + // the page rendered a clean day, which made this harness quietly unable to + // photograph the one case it most needs to: a trip with something owed. + await debugSeedStore('completed_bookings', jsonEncode(completed)); + await debugSeedStore('skipped_bookings', jsonEncode(skipped)); + + tester.view.physicalSize = Size(width * 3, height * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: Size(width, height), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: const ActivityPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('shots/activity_$name.png'), + ); + } + + testWidgets('a round s worth of deliveries', (tester) async { + await shoot( + tester, + 'round', + completed: [ + finished( + 'DM2401', + 'Joe Mathew', + 'Vidhya Kitchen', + type: 'delivery', + minsAgo: 22, + ), + finished( + 'DM2402', + 'Arun Prakash', + 'Vidhya Kitchen', + type: 'delivery', + minsAgo: 51, + ), + finished( + 'DM2403', + 'Priya Nair', + 'Joe Kitchen', + type: 'delivery', + minsAgo: 96, + cash: 2450, + ), + finished( + 'DM2404', + 'Ravi Kumar', + 'Joe Kitchen', + type: 'delivery', + minsAgo: 140, + daysAgo: 1, + ), + ], + ); + }); + + testWidgets('nothing done yet', (tester) async { + // Structural, not a golden: the empty state draws a looping animation, + // and its frame at settle-timeout differs between an isolated run and + // the parallel full suite — the one golden in this file that flaked on + // scheduling rather than on design. The look is pinned well enough by + // MilerEmptyState's own use everywhere; what this run holds is that an + // empty store renders the empty state and nothing throws. + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userId': 1, 'userid': 1}); + await debugSeedStore('completed_bookings', jsonEncode(const [])); + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: ActivityPage()), + ), + ); + await tester.pump(const Duration(seconds: 2)); + expect(tester.takeException(), isNull); + // The copy is gone — the artwork carries it now. What the shot pins is + // that the state renders at all, with its one control. + expect(find.textContaining('Go to'), findsWidgets); + }); + + testWidgets('one delivery record', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userId': 1, 'userid': 1}); + + tester.view.physicalSize = const Size(390 * 3, 1500 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 1500), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + home: DeliveryDetailsPage( + stop: finished( + 'DM-BK-16DA518D-6381', + 'Joe Mathew', + 'Vidhya Kitchen', + type: 'delivery', + minsAgo: 22, + cash: 2450, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('shots/activity_record.png'), + ); + }); + + testWidgets('three trips, one of them with a problem', (tester) async { + // ── The two-second test ── + // + // At arm's length a rider must be able to say "Trip 2 had a problem", and + // after opening it, "Arun is the one I have to go back to". This is the + // shot that answers whether the page does that. + Map at( + String id, + String who, + int hour, { + double cash = 0, + }) { + final t = DateTime(now.year, now.month, now.day, hour, 15); + return { + ...finished(id, who, 'Vidhya Kitchen', type: 'delivery', cash: cash), + 'completedat': t.toIso8601String(), + 'expected_pickup_time': t.toIso8601String(), + }; + } + + final skipAt = DateTime(now.year, now.month, now.day, 14, 40); + await shoot( + tester, + 'trips', + height: 1500, + completed: [ + at('DM2401', 'Joe Mathew', 9), + at('DM2402', 'Sarah', 10), + at('DM2403', 'Priya Nair', 14, cash: 2450), + at('DM2404', 'Meera', 19), + ], + skipped: [ + { + ...finished('DM2405', 'Arun', 'Vidhya Kitchen', type: 'delivery'), + 'orderstatus': 'skipped', + 'skipreason': 'Customer unavailable', + 'skippedat': skipAt.toIso8601String(), + 'skippedday': + '${skipAt.year}-${skipAt.month.toString().padLeft(2, '0')}-' + '${skipAt.day.toString().padLeft(2, '0')}', + 'expected_pickup_time': skipAt.toIso8601String(), + }, + ], + ); + }); + + testWidgets('a 320pt phone', (tester) async { + await shoot( + tester, + 'small', + width: 320, + completed: [ + finished( + 'DM2401', + 'Joe Mathew', + 'Vidhya Kitchen', + type: 'delivery', + minsAgo: 22, + ), + finished( + 'DM2403', + 'Priya Nair', + 'Joe Kitchen', + type: 'delivery', + minsAgo: 96, + cash: 2450, + ), + ], + ); + }); +} diff --git a/test/activity_trips_test.dart b/test/activity_trips_test.dart new file mode 100644 index 0000000..362bea5 --- /dev/null +++ b/test/activity_trips_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/route_order.dart'; +import 'package:miler/data/service_day.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ACTIVITY IS TODAY, IN THREE TRIPS +/// +/// The page answered "everything you have ever finished, newest first", cut +/// into date bands. The rider's question is narrower and shaped differently: +/// **how did Trip 2 go.** He was given three runs this morning; he wants the +/// record back in the same three. +/// +/// These pin the four properties that makes it that record rather than a feed: +/// the grouping is the hub's, the order inside a trip is the hub's, the day is +/// today's, and nothing internal leaks onto the screen. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({'userid': 38}); + ServiceProfile.setActive(ServiceProfile.milkMan); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map done( + String id, { + int step = 0, + DateTime? at, + String status = 'delivered', + String? tripId, + String? day, + }) => { + 'orderid': id, + 'pickupid': int.tryParse(id) ?? id.hashCode.abs(), + 'orderstatus': status, + 'step': step, + 'pickupcustomer': 'Customer $id', + if (tripId != null) 'tripid': tripId, + if (at != null) 'expected_pickup_time': at.toIso8601String(), + if (at != null) 'completedat': at.toIso8601String(), + 'completedday': day ?? ServiceDay.today, + }; + + List ids(List> s) => [ + for (final x in s) x['orderid'] as String, + ]; + + group('grouped into the rider’s three trips', () { + test('a three-run day produces Trip 1, Trip 2 and Trip 3', () { + final trips = Trip.groupIntoTrips([ + done('a', at: DateTime(2026, 8, 21, 9)), + done('b', at: DateTime(2026, 8, 21, 14)), + done('c', at: DateTime(2026, 8, 21, 19)), + ]); + expect(trips.map((t) => t.tripNumber), [1, 2, 3]); + }); + + test('a completed stop stays under the trip it belonged to', () { + final trips = Trip.groupIntoTrips([ + done('morning-1', at: DateTime(2026, 8, 21, 8)), + done('morning-2', at: DateTime(2026, 8, 21, 11)), + done('evening-1', at: DateTime(2026, 8, 21, 18)), + ]); + final slots = trips.slots; + expect(ids(slots[0]!.stops), ['morning-1', 'morning-2']); + expect(slots[1], isNull, reason: 'nothing was done in the afternoon'); + expect(ids(slots[2]!.stops), ['evening-1']); + }); + + test('the hub’s own trip id beats the clock', () { + // Two stops eleven hours apart that the hub calls one run. The day-part + // rule is a fallback for when it says nothing — never an override. + final trips = Trip.groupIntoTrips([ + done('a', at: DateTime(2026, 8, 21, 8), tripId: 'T7'), + done('b', at: DateTime(2026, 8, 21, 19), tripId: 'T7'), + ]); + expect(trips.length, 1); + expect(ids(trips.single.stops), ['a', 'b']); + }); + + test('a trip with no dates at all still lands in a slot', () { + // No timestamps anywhere. It must not vanish, and it must not be + // labelled with an internal data-quality word. + final trips = Trip.groupIntoTrips([done('a'), done('b')]); + expect(trips.single.stops, hasLength(2)); + expect(trips.slots[0], isNotNull); + }); + }); + + group('the admin sequence survives into the record', () { + test('a trip shows its stops in the hub’s order, not by finish time', () { + // Finished out of order — c first, a last — but the run was planned + // a → b → c, and the record of a planned run is the plan. + final trips = Trip.groupIntoTrips([ + done('c', step: 3, at: DateTime(2026, 8, 21, 9, 5)), + done('a', step: 1, at: DateTime(2026, 8, 21, 9, 40)), + done('b', step: 2, at: DateTime(2026, 8, 21, 9, 20)), + ]); + expect(ids(trips.single.stops), ['a', 'b', 'c']); + expect(RouteOrder.sequenceOf(trips.single.stops.first), 1); + }); + + test('grouping never re-bases the sequence per trip', () { + final trips = Trip.groupIntoTrips([ + done('a5', step: 5, at: DateTime(2026, 8, 21, 15)), + done('a4', step: 4, at: DateTime(2026, 8, 21, 14)), + done('m1', step: 1, at: DateTime(2026, 8, 21, 9)), + ]); + expect(ids(trips.slots[1]!.stops), ['a4', 'a5']); + expect(RouteOrder.sequenceOf(trips.slots[1]!.stops.first), 4); + }); + }); + + group('today only, without a timer', () { + test('yesterday is not today’s activity', () { + final yesterday = ServiceDay.stamp( + DateTime.now().subtract(const Duration(days: 1)), + ); + expect(ServiceDay.belongsToToday(done('a', day: yesterday)), isFalse); + expect(ServiceDay.belongsToToday(done('b')), isTrue); + }); + + test('the rollover needs nothing to run at midnight', () { + // The same record, asked about on two different days. Nothing was + // deleted and no timer fired — the answer is computed at read time, + // which is the only thing that survives the app being closed at 23:59. + final row = done('a', day: '2026-08-21'); + expect(ServiceDay.belongsToToday(row, now: '2026-08-21'), isTrue); + expect(ServiceDay.belongsToToday(row, now: '2026-08-22'), isFalse); + }); + + test('a record with no date is kept, quietly', () { + // A payload defect is not evidence the work happened yesterday, and it + // is not something to print on the rider's screen either. + final row = {'orderid': 'x', 'orderstatus': 'delivered'}; + expect(ServiceDay.belongsToToday(row), isTrue); + expect(ServiceDay.of(row), ''); + }); + + test('a timestamp is used when no day stamp was written', () { + final row = { + 'orderid': 'x', + 'completedat': DateTime.now().toIso8601String(), + }; + expect(ServiceDay.belongsToToday(row), isTrue); + }); + + test('the store and the reader agree on what a day is', () async { + await addCompletedBookings([done('a')], terminalStatus: 'delivered'); + final filed = (await getCompletedBookings()).single; + expect( + ServiceDay.of(filed), + ServiceDay.today, + reason: + 'two implementations of a date format is how a shift ends up ' + 'half in one day and half in the next', + ); + }); + }); + + group('nothing internal reaches the rider', () { + test('the work-type mark never says MILK', () { + // The service profile stays — it is what separates one rider's records + // from another's. What goes is printing its name on his screen. + expect(ServiceProfile.milkMan.workTypeLabel, isNot('MILK')); + expect(ServiceProfile.milkMan.workTypeLabel, 'DELIVERY'); + // Logistics never had a single-type mark and still does not — its stops + // genuinely differ, so each one names itself. + expect(ServiceProfile.parcel.workTypeLabel, isNull); + expect(ServiceProfile.parcel.usesSingleWorkType, isFalse); + }); + + test('records are still isolated by owner, in the data', () async { + await addCompletedBookings([done('a')], terminalStatus: 'delivered'); + expect(await getCompletedOrderIds(), contains('a')); + + SharedPreferences.setMockInitialValues({'userid': 99}); + expect( + await getCompletedOrderIds(), + isEmpty, + reason: 'separation is an ownership rule, not a label on a card', + ); + }); + + test('the other line’s records are not this one’s', () async { + await addCompletedBookings([done('a')], terminalStatus: 'delivered'); + ServiceProfile.setActive(ServiceProfile.parcel); + expect(await getCompletedOrderIds(), isEmpty); + }); + }); + + group('delivered and skipped stay different things', () { + test('a skip is filed as a skip, never as a cancellation', () async { + await addCompletedBookings( + [done('a', status: 'outfordelivery')], + cancelled: true, + terminalStatus: 'skipped', + ); + final filed = (await getCompletedBookings()).single; + expect(filed['orderstatus'], 'skipped'); + expect(filed['orderstatus'], isNot('cancelled')); + }); + + test('a skip keeps the reason the rider gave', () async { + await addSkippedBooking(done('a'), reason: 'Shutter down'); + final parked = (await getSkippedBookings()).single; + expect(parked['skipreason'], 'Shutter down'); + expect(parked['orderstatus'], 'skipped'); + }); + }); +} diff --git a/test/api_status_test.dart b/test/api_status_test.dart new file mode 100644 index 0000000..3b93759 --- /dev/null +++ b/test/api_status_test.dart @@ -0,0 +1,229 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/api_status.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A STATUS THIS BUILD HAS NEVER HEARD OF MUST NOT BE A SUCCESS +/// +/// Statuses were compared as string literals wherever a screen needed one, so +/// an unrecognised value took whichever branch the condition happened to fall +/// through to — and the conditions were written as `!= 'Cancelled'`, which +/// means a new backend status read as *fine*. +/// +/// The backend will add values this build does not know. These pin the two +/// rules that make that safe: unknown parses to `unknown`, and `unknown` is +/// excluded from every "this is finished / this worked" predicate. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + statusTranslation(); + + group('assignment', () { + test('parses the contract\'s values', () { + expect(AssignmentStatus.parse('Assigned'), AssignmentStatus.assigned); + expect(AssignmentStatus.parse('Accepted'), AssignmentStatus.accepted); + expect(AssignmentStatus.parse('Rejected'), AssignmentStatus.rejected); + expect(AssignmentStatus.parse('Reassigned'), AssignmentStatus.reassigned); + expect(AssignmentStatus.parse('Completed'), AssignmentStatus.completed); + expect(AssignmentStatus.parse('Cancelled'), AssignmentStatus.cancelled); + }); + + test('survives case and separator drift on the wire', () { + expect(AssignmentStatus.parse('ACCEPTED'), AssignmentStatus.accepted); + expect(AssignmentStatus.parse(' accepted '), AssignmentStatus.accepted); + }); + + test('an unknown value is unknown, and is not settled', () { + final s = AssignmentStatus.parse('Escalated_To_Ops'); + expect(s, AssignmentStatus.unknown); + expect( + s.isSettled, + isFalse, + reason: 'a status this build cannot read must not be filed as done', + ); + expect(s.isLive, isFalse); + expect(s.needsDecision, isFalse); + }); + + test('null and empty do not throw', () { + expect(AssignmentStatus.parse(null), AssignmentStatus.unknown); + expect(AssignmentStatus.parse(''), AssignmentStatus.unknown); + }); + }); + + group('booking', () { + test('parses the contract\'s values', () { + expect( + BookingStatus.parse('Pending_Pickup'), + BookingStatus.pendingPickup, + ); + expect( + BookingStatus.parse('Miler_Assigned'), + BookingStatus.milerAssigned, + ); + expect( + BookingStatus.parse('Pickup_Scheduled'), + BookingStatus.pickupScheduled, + ); + expect(BookingStatus.parse('Picked_Up'), BookingStatus.pickedUp); + expect( + BookingStatus.parse('Converted_To_Consignment'), + BookingStatus.convertedToConsignment, + ); + }); + + // ── pickup-complete is the pivot ── + // + // It converts the booking into a consignment, so both rungs past it are + // "collected". A check that only looked for Picked_Up would treat a + // converted booking as still needing collection. + test('collection covers both rungs past the pivot', () { + expect(BookingStatus.pickedUp.isCollected, isTrue); + expect(BookingStatus.convertedToConsignment.isCollected, isTrue); + expect(BookingStatus.pickupScheduled.isCollected, isFalse); + }); + + // The server refuses cancellation once picked up; the client must not offer + // a control whose only outcome is a 400. + test('cancellation is refused once picked up', () { + expect(BookingStatus.pendingPickup.canCancel, isTrue); + expect(BookingStatus.milerAssigned.canCancel, isTrue); + expect(BookingStatus.pickedUp.canCancel, isFalse); + expect(BookingStatus.convertedToConsignment.canCancel, isFalse); + expect(BookingStatus.unknown.canCancel, isFalse); + }); + }); + + group('consignment', () { + test('parses every state the contract lists', () { + const wire = { + '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, + }; + wire.forEach((raw, expected) { + expect(ConsignmentStatus.parse(raw), expected, reason: raw); + }); + }); + + // Deliver and skip are legal only from Out_for_Delivery — anything else is + // a 400. Offering the control elsewhere offers a guaranteed failure. + test('only Out_for_Delivery is deliverable', () { + expect(ConsignmentStatus.outForDelivery.isDeliverable, isTrue); + for (final s in ConsignmentStatus.values) { + if (s == ConsignmentStatus.outForDelivery) continue; + expect(s.isDeliverable, isFalse, reason: '$s must not offer deliver'); + } + }); + + test('returning and exception states are closed, unknown is not', () { + expect(ConsignmentStatus.rtoInitiated.isReturning, isTrue); + expect(ConsignmentStatus.returnedToSender.isReturning, isTrue); + expect(ConsignmentStatus.missing.isException, isTrue); + expect(ConsignmentStatus.damaged.isException, isTrue); + expect(ConsignmentStatus.delivered.isClosed, isTrue); + expect( + ConsignmentStatus.unknown.isClosed, + isFalse, + reason: 'an unreadable status must not close a consignment', + ); + }); + }); + + group('rider availability', () { + test('the break value is Break, not On_Break', () { + expect(RiderAvailability.parse('Break'), RiderAvailability.onBreak); + expect(RiderAvailability.onBreak.wire, 'Break'); + expect( + RiderAvailability.parse('On_Break'), + RiderAvailability.unknown, + reason: 'On_Break is the obvious guess and the wrong one', + ); + }); + + test('every known value round-trips through the wire form', () { + for (final s in RiderAvailability.values) { + if (s == RiderAvailability.unknown) continue; + expect(RiderAvailability.parse(s.wire), s, reason: s.name); + } + }); + + test('an unknown status is never echoed back as though understood', () { + final s = RiderAvailability.parse('On_Refuelling_Stop'); + expect(s, RiderAvailability.unknown); + expect(s.wire, 'Offline'); + expect(s.isWorking, isFalse); + }); + }); +} + +/// ───────────────────────────────────────────────────────────────────────── +/// THE ONE PLACE THE CONTRACT BECOMES THE UI'S VOCABULARY +/// +/// `legacyStatusFromNew` was a switch on raw strings whose `default` returned +/// the value unchanged. Two things went wrong quietly: `Pending_Pickup` and +/// `Created` were not listed and fell through as themselves, and any status the +/// backend added tomorrow would arrive in the UI as an unrecognised string the +/// row logic then had to guess at. +/// ───────────────────────────────────────────────────────────────────────── +void statusTranslation() { + group('booking status → the row vocabulary', () { + test('everything the rider still has to decide lands as assigned', () { + for (final raw in const ['Pending_Pickup', 'Created', 'Miler_Assigned']) { + expect(ApiConfig.legacyStatusFromNew(raw), 'assigned', reason: raw); + } + }); + + test('the working rungs keep their meaning', () { + // ── `Pickup_Scheduled` is the ACCEPTED rung ── + // + // This expected `active`, and that expectation was the bug: `active` + // means *the rider is on the stop* to every row in this app, and + // `stopStateOf` reads a raw `active` ahead of the local accepted record. + // So the moment the queue came back after an accept, the stop reported as + // arrived — the selection bar offered **Mark as Picked** for a kitchen + // the rider had not reached, and arrival, the one rung with a real + // endpoint behind it, could never be recorded or seen by the hub. + // + // The backend writes this status when the rider accepts; the console has + // always mapped it to *accepted*. The two applications were reading one + // status two different ways. + expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted'); + expect(ApiConfig.legacyStatusFromNew('At_Customer'), 'arrived'); + expect(ApiConfig.legacyStatusFromNew('Picked_Up'), 'Picked up'); + expect( + ApiConfig.legacyStatusFromNew('Converted_To_Consignment'), + 'picked', + ); + expect(ApiConfig.legacyStatusFromNew('Cancelled'), 'cancelled'); + }); + + // ── Wrong-but-safe beats wrong-and-settled ── + // + // A status this build cannot classify must never render as picked, + // cancelled or otherwise finished. It renders as undecided, which is the + // state a rider can still act on. + test('an unknown status never becomes a settled one', () { + for (final raw in const [ + 'In_Transit', + 'Escalated_To_Ops', + '', + 'total nonsense', + ]) { + final legacy = ApiConfig.legacyStatusFromNew(raw); + expect(legacy, '', reason: raw); + expect(legacy, isNot('picked')); + expect(legacy, isNot('cancelled')); + expect(legacy, isNot('Picked up')); + } + expect(ApiConfig.legacyStatusFromNew(null), ''); + }); + }); +} diff --git a/test/app_bar_glass_test.dart b/test/app_bar_glass_test.dart index 5f12077..3c4f932 100644 --- a/test/app_bar_glass_test.dart +++ b/test/app_bar_glass_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -33,7 +34,10 @@ void main() { group('MilerAppBar', () { testWidgets('shows the page name and nothing beneath it', (tester) async { - await pump(tester, const Scaffold(appBar: MilerAppBar(title: 'Activity'))); + await pump( + tester, + const Scaffold(appBar: MilerAppBar(title: 'Activity')), + ); expect(find.text('Activity'), findsOneWidget); expect( @@ -43,20 +47,64 @@ void main() { ); }); - testWidgets('wears the frosted brand wash, not a solid fill', - (tester) async { - await pump(tester, const Scaffold(appBar: MilerAppBar(title: 'Activity'))); + // ── The wash gave way to the brand ── + // + // The four tabs wore a 7% frosted brand wash flattened against white while + // Home wore solid brand. Switching tab therefore changed the colour of the + // top 76pt of the phone AND the colour of the status-bar glyphs, which + // reads as arriving in a different app rather than a different page. + // + // One ground now, and everything on it inverts. The wash survives as + // `milerGlassSurface()` for the sheets, which sit over content and still + // want it. + testWidgets('is solid brand, so every tab shares one chrome', ( + tester, + ) async { + await pump( + tester, + const Scaffold(appBar: MilerAppBar(title: 'Activity')), + ); final bar = tester.widget(find.byType(AppBar)); - expect(bar.backgroundColor, Colors.transparent); - expect(bar.flexibleSpace, isNotNull); - // A blur is the difference between glass and a tinted box: content - // passing under the bar softens instead of sliding under a lid. - expect(find.byType(BackdropFilter), findsOneWidget); + expect(bar.backgroundColor, ColorConstants.primary); + expect( + bar.flexibleSpace, + isNull, + reason: 'a solid ground needs no surface layered over it', + ); + }); + + testWidgets('inverts everything drawn on it', (tester) async { + await pump( + tester, + const Scaffold(appBar: MilerAppBar(title: 'Activity', showBack: true)), + ); + + final title = tester.widget(find.text('Activity')); + expect( + title.style?.color, + Colors.white, + reason: 'white on #960019 is the only combination that clears AA here', + ); + + final back = tester.widget(find.byIcon(LucideIcons.arrowLeft)); + expect(back.color, Colors.white); + + final bar = tester.widget(find.byType(AppBar)); + expect( + bar.systemOverlayStyle?.statusBarIconBrightness, + Brightness.light, + reason: + 'dark glyphs on maroon is the one contrast failure a rider ' + 'cannot work around by tilting the phone', + ); }); testWidgets('closes with no rule under it', (tester) async { - await pump(tester, const Scaffold(appBar: MilerAppBar(title: 'Activity'))); + await pump( + tester, + const Scaffold(appBar: MilerAppBar(title: 'Activity')), + ); // The wash is a surface, and a surface against an untinted page is // already an edge. A hairline under it drew that boundary a second time — @@ -65,8 +113,9 @@ void main() { expect(bar.bottom, isNull); }); - testWidgets('keeps a trailing slot for the pages that need one', - (tester) async { + testWidgets('keeps a trailing slot for the pages that need one', ( + tester, + ) async { await pump( tester, Scaffold( @@ -80,26 +129,36 @@ void main() { }); }); - group('appTopBar', () { - testWidgets('uses the same wash as the tab bars', (tester) async { - await pump(tester, Scaffold(appBar: milerPageBar('Rewards', onBack: () {}))); + group('the pushed-page bar', () { + // The rule is unchanged and is the whole point of this file: a pushed page + // and a tab must be the same object at the top of the phone. Only the + // answer changed — both are solid brand now, rather than both being a wash. + testWidgets('is the same ground as the tab bars', (tester) async { + await pump( + tester, + Scaffold(appBar: milerPageBar('Rewards', onBack: () {})), + ); final bar = tester.widget(find.byType(AppBar)); - expect(bar.backgroundColor, Colors.transparent); - expect(find.byType(BackdropFilter), findsOneWidget); - expect(bar.bottom, isNull, reason: 'no rule under the glass here either'); + expect(bar.backgroundColor, ColorConstants.primary); + expect(bar.flexibleSpace, isNull); + expect(bar.bottom, isNull, reason: 'no rule under the bar here either'); }); }); + // The wash survives as `milerGlassSurface()`, which the SHEETS use — they sit + // over scrolling content and genuinely want a translucent surface, where a + // bar sits over nothing and does not. This still pins what the wash has to + // be, wherever it is used. test('the wash is a brand tint light enough to carry near-black type', () { - // Flattened against white, which is what both the real bars and Home's - // pinned header composite it over. + // Flattened against white, which is what the sheets composite it over. final flat = Color.alphaBlend(ColorConstants.glassRed, Colors.white); expect( flat.computeLuminance(), greaterThan(0.85), - reason: 'a saturated red bar would force white text onto a mid-tone red, ' - 'the weakest contrast pairing in the palette', + reason: + 'a saturated red surface would force white text onto a mid-tone ' + 'red, the weakest contrast pairing in the palette', ); }); } diff --git a/test/assignment_lookup_test.dart b/test/assignment_lookup_test.dart index f1a55b1..87c944c 100644 --- a/test/assignment_lookup_test.dart +++ b/test/assignment_lookup_test.dart @@ -3,11 +3,7 @@ import 'package:miler/data/assignment_lookup.dart'; /// Rows shaped like GET /miler/assignments (models.BookingAssignment), which the /// backend returns ordered by assignedat DESC. -Map row( - int assignmentId, - dynamic bookingId, - String status, -) => +Map row(int assignmentId, dynamic bookingId, String status) => { 'bookingassignmentid': assignmentId, 'bookingid': bookingId, @@ -55,15 +51,17 @@ void main() { expect(index['9'], 9020); }); - test('treats Accepted as live so re-accept resolves the same assignment', - () { - final index = AssignmentLookup.buildIndex([ - row(9031, 11, 'Cancelled'), - row(9030, 11, 'Accepted'), - ]); + test( + 'treats Accepted as live so re-accept resolves the same assignment', + () { + final index = AssignmentLookup.buildIndex([ + row(9031, 11, 'Cancelled'), + row(9030, 11, 'Accepted'), + ]); - expect(index['11'], 9030); - }); + expect(index['11'], 9030); + }, + ); test('normalises int and string booking ids to the same key', () { final index = AssignmentLookup.buildIndex([ @@ -81,28 +79,31 @@ void main() { 'bookingassignmentid': '9050', 'bookingid': 14, 'assignmentstatus': 'Assigned', - } + }, ]); expect(index['14'], 9050); }); - test('skips rows missing either id rather than mapping them to garbage', () { - final index = AssignmentLookup.buildIndex([ - {'bookingid': 15, 'assignmentstatus': 'Assigned'}, - { - 'bookingassignmentid': 9060, - 'assignmentstatus': 'Assigned' - }, - { - 'bookingassignmentid': null, - 'bookingid': null, - 'assignmentstatus': 'Assigned' - }, - ]); + test( + 'skips rows missing either id rather than mapping them to garbage', + () { + final index = AssignmentLookup.buildIndex([ + {'bookingid': 15, 'assignmentstatus': 'Assigned'}, + { + 'bookingassignmentid': 9060, + 'assignmentstatus': 'Assigned', + }, + { + 'bookingassignmentid': null, + 'bookingid': null, + 'assignmentstatus': 'Assigned', + }, + ]); - expect(index, isEmpty); - }); + expect(index, isEmpty); + }, + ); test('ignores non-map entries', () { final index = AssignmentLookup.buildIndex([ diff --git a/test/auth_design_test.dart b/test/auth_design_test.dart index c28f9d9..2d3b9c3 100644 --- a/test/auth_design_test.dart +++ b/test/auth_design_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -24,18 +25,14 @@ void main() { await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), - builder: (_, _) => MaterialApp( - theme: AppTheme.light, - home: child, - ), + builder: (_, _) => MaterialApp(theme: AppTheme.light, home: child), ), ); await tester.pumpAndSettle(); } Widget codeRow(int count, {bool error = false}) { - final controllers = - List.generate(count, (_) => TextEditingController()); + final controllers = List.generate(count, (_) => TextEditingController()); final nodes = List.generate(count, (_) => FocusNode()); return Scaffold( body: Padding( @@ -56,8 +53,9 @@ void main() { // width happens to divide that way; it has no headroom and cannot adapt. for (final count in [4, 6]) { for (final width in [320.0, 360.0, 390.0, 430.0]) { - testWidgets('$count boxes fit a ${width.toInt()}pt screen', - (tester) async { + testWidgets('$count boxes fit a ${width.toInt()}pt screen', ( + tester, + ) async { await pump(tester, codeRow(count), screen: Size(width, 780)); expect(tester.takeException(), isNull); expect(find.byType(TextField), findsNCount(count)); @@ -90,14 +88,18 @@ void main() { }); group('AuthPrimaryButton', () { - testWidgets('the label is not squeezed by the theme form metrics', - (tester) async { + testWidgets('the label is not squeezed by the theme form metrics', ( + tester, + ) async { await pump( tester, const Scaffold( body: Padding( padding: EdgeInsets.all(24), - child: AuthPrimaryButton(label: 'Verify & Continue', onPressed: _noop), + child: AuthPrimaryButton( + label: 'Verify & Continue', + onPressed: _noop, + ), ), ), ); @@ -152,8 +154,11 @@ void main() { final body = tester.getRect(find.byType(SingleChildScrollView)); final footer = tester.getRect(find.byType(ElevatedButton)); - expect(footer.top, greaterThanOrEqualTo(body.bottom - 0.01), - reason: 'the CTA sits below the scroll area, not inside it'); + expect( + footer.top, + greaterThanOrEqualTo(body.bottom - 0.01), + reason: 'the CTA sits below the scroll area, not inside it', + ); }); testWidgets('the back target clears the 44dp floor', (tester) async { @@ -166,19 +171,22 @@ void main() { children: const [], ), ); - final back = tester.getRect(find.byIcon(Icons.arrow_back_rounded)); + final back = tester.getRect(find.byIcon(LucideIcons.arrowLeft)); // The glyph is small; the tappable box around it must not be. - final target = tester.getRect(find.ancestor( - of: find.byIcon(Icons.arrow_back_rounded), - matching: find.byType(InkResponse), - )); + final target = tester.getRect( + find.ancestor( + of: find.byIcon(LucideIcons.arrowLeft), + matching: find.byType(InkResponse), + ), + ); expect(back.width, lessThan(target.width)); expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget)); expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget)); }); - testWidgets('no step rail when the screen is outside the flow', - (tester) async { + testWidgets('no step rail when the screen is outside the flow', ( + tester, + ) async { await pump( tester, AuthScaffold( @@ -215,13 +223,13 @@ void main() { /// layout change. Both are asserted rather than eyeballed. group('AuthBrandBanner', () { Widget bannered() => AuthScaffold( - step: 1, - banner: const AuthBrandBanner(), - title: 'Enter your number', - subtitle: 'We use it to sign you in.', - footer: const AuthPrimaryButton(label: 'Continue', onPressed: _noop), - children: [Container(height: 56, color: Colors.white)], - ); + step: 1, + banner: const AuthBrandBanner(), + title: 'Enter your number', + subtitle: 'We use it to sign you in.', + footer: const AuthPrimaryButton(label: 'Continue', onPressed: _noop), + children: [Container(height: 56, color: Colors.white)], + ); for (final width in [320.0, 360.0, 390.0, 430.0]) { testWidgets('lays out on a ${width.toInt()}pt screen', (tester) async { @@ -233,23 +241,27 @@ void main() { // The block must not run away with the screen. Half the height is the // point at which the sheet stops being the subject of the page. final banner = tester.getRect(find.byType(AuthBrandBanner)); - expect(banner.height, lessThan(720 * 0.5), - reason: 'the brand block is a header, not the page'); + expect( + banner.height, + lessThan(720 * 0.5), + reason: 'the brand block is a header, not the page', + ); }); } testWidgets('the wordmark keeps its aspect ratio', (tester) async { await pump(tester, bannered()); - final mark = tester.getRect(find.image( - const AssetImage('assets/images/doormile-logo.png'), - )); + final mark = tester.getRect( + find.image(const AssetImage('assets/images/doormile-logo.png')), + ); // Source is 748×100. A squashed mark is the classic symptom of a banner // being re-laid-out without the ratio being held explicitly. expect(mark.width / mark.height, closeTo(748 / 100, 0.05)); }); - testWidgets('it gets out of the way when the keyboard opens', - (tester) async { + testWidgets('it gets out of the way when the keyboard opens', ( + tester, + ) async { await pump(tester, bannered()); final atRest = tester.getRect(find.byType(AuthBrandBanner)).height; @@ -265,8 +277,11 @@ void main() { await tester.pumpAndSettle(); final typing = tester.getRect(find.byType(AuthBrandBanner)).height; - expect(typing, lessThan(atRest), - reason: 'the block halves itself so the field stays visible'); + expect( + typing, + lessThan(atRest), + reason: 'the block halves itself so the field stays visible', + ); // And the mark is still there — it shrinks, it does not disappear. expect(find.byType(AuthBrandBanner), findsOneWidget); }); @@ -289,8 +304,11 @@ void main() { addTearDown(tester.view.resetViewInsets); await tester.pumpAndSettle(); - expect(tester.getRect(find.byType(AuthBrandBanner)).height, first, - reason: 'a compact banner has nowhere to shrink to'); + expect( + tester.getRect(find.byType(AuthBrandBanner)).height, + first, + reason: 'a compact banner has nowhere to shrink to', + ); }); // ── The banner used to eat the back button ── @@ -309,12 +327,14 @@ void main() { children: const [], ), ); - expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget); - final target = tester.getRect(find.ancestor( - of: find.byIcon(Icons.arrow_back_rounded), - matching: find.byType(InkResponse), - )); + final target = tester.getRect( + find.ancestor( + of: find.byIcon(LucideIcons.arrowLeft), + matching: find.byType(InkResponse), + ), + ); expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget)); expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget)); @@ -341,8 +361,10 @@ void main() { ), ); expect(find.text('That code is wrong.'), findsOneWidget); - expect(tester.getSize(find.byType(AuthInlineError)).height, - greaterThan(0)); + expect( + tester.getSize(find.byType(AuthInlineError)).height, + greaterThan(0), + ); }); }); } diff --git a/test/bookings_trip_label_test.dart b/test/bookings_trip_label_test.dart index 7272470..038eb12 100644 --- a/test/bookings_trip_label_test.dart +++ b/test/bookings_trip_label_test.dart @@ -38,9 +38,7 @@ void main() { stop('A', '2026-08-06T09:00:00', '2026-08-06T12:00:00'), stop('B', '2026-08-06T09:00:00', '2026-08-06T12:00:00'), ]; - final afternoon = [ - stop('C', '2026-08-06T14:00:00', '2026-08-06T17:00:00'), - ]; + final afternoon = [stop('C', '2026-08-06T14:00:00', '2026-08-06T17:00:00')]; test('trips are numbered by their slot, earliest first', () { final numbers = numberTrips([...afternoon, ...morning]); diff --git a/test/bottom_nav_compact_test.dart b/test/bottom_nav_compact_test.dart index 52de876..b80148e 100644 --- a/test/bottom_nav_compact_test.dart +++ b/test/bottom_nav_compact_test.dart @@ -52,8 +52,9 @@ void main() { if (n is! ScrollUpdateNotification) return false; final delta = n.scrollDelta ?? 0; if (delta == 0) return false; - accum.value = - (accum.value * delta > 0) ? accum.value + delta : delta; + accum.value = (accum.value * delta > 0) + ? accum.value + delta + : delta; if (delta > 0 && compact.value == 0 && accum.value > 24) { accum.value = 0; compact.value = 1; @@ -96,8 +97,9 @@ void main() { double barHeight(WidgetTester tester) => tester.getSize(find.byKey(barKey)).height; - testWidgets('scrolling down shrinks the bar; scrolling back restores it', - (tester) async { + testWidgets('scrolling down shrinks the bar; scrolling back restores it', ( + tester, + ) async { final compact = ValueNotifier(0.0); final accum = ValueNotifier(0.0); addTearDown(compact.dispose); @@ -117,8 +119,9 @@ void main() { expect(barHeight(tester), resting); }); - testWidgets('the bar never leaves — the rider can always change tab', - (tester) async { + testWidgets('the bar never leaves — the rider can always change tab', ( + tester, + ) async { // This is the whole reason the behaviour changed. Whatever the rider does // with his thumb, the tab targets stay on screen and stay tappable. final compact = ValueNotifier(0.0); @@ -130,10 +133,16 @@ void main() { for (final drag in const [-300.0, -600.0, -1200.0]) { await tester.drag(find.byType(ListView), Offset(0, drag)); await tester.pumpAndSettle(); - expect(barHeight(tester), greaterThanOrEqualTo(compactHeight), - reason: 'the bar must never collapse past its compact height'); - expect(find.byType(Icon), findsOneWidget, - reason: 'and its icons must stay on screen'); + expect( + barHeight(tester), + greaterThanOrEqualTo(compactHeight), + reason: 'the bar must never collapse past its compact height', + ); + expect( + find.byType(Icon), + findsOneWidget, + reason: 'and its icons must stay on screen', + ); } }); @@ -155,20 +164,27 @@ void main() { // The Align is what gives the room back; the Text keeps its own size and is // clipped, so measuring the Text would report the same height either way. final align = tester.getSize( - find.ancestor(of: find.byKey(labelKey), matching: find.byType(Align)).first, + find + .ancestor(of: find.byKey(labelKey), matching: find.byType(Align)) + .first, + ); + expect( + align.height, + lessThan(1), + reason: 'the label must hand its height back, not just go invisible', ); - expect(align.height, lessThan(1), - reason: 'the label must hand its height back, not just go invisible'); }); - testWidgets('a page too short to scroll never compacts the bar', - (tester) async { + testWidgets('a page too short to scroll never compacts the bar', ( + tester, + ) async { final compact = ValueNotifier(0.0); final accum = ValueNotifier(0.0); addTearDown(compact.dispose); addTearDown(accum.dispose); - await tester - .pumpWidget(shell(compact: compact, accum: accum, scrollable: false)); + await tester.pumpWidget( + shell(compact: compact, accum: accum, scrollable: false), + ); await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); diff --git a/test/bottom_nav_indicator_test.dart b/test/bottom_nav_indicator_test.dart index bdc03c3..3bb648c 100644 --- a/test/bottom_nav_indicator_test.dart +++ b/test/bottom_nav_indicator_test.dart @@ -66,13 +66,17 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(bar(selected: selected, initial: start)); await tester.pumpAndSettle(); - expect(pillLeft(tester), closeTo(slot * start, 0.5), - reason: 'tab $start should be the one wearing the pill'); + expect( + pillLeft(tester), + closeTo(slot * start, 0.5), + reason: 'tab $start should be the one wearing the pill', + ); } }); - testWidgets('Account → Home crosses Earnings and Bookings on the way', - (tester) async { + testWidgets('Account → Home crosses Earnings and Bookings on the way', ( + tester, + ) async { // The failing case the rider described, measured directly. final selected = ValueNotifier(3); addTearDown(selected.dispose); @@ -94,16 +98,26 @@ void main() { // at some point. A pair of cross-fading per-tab pills produces *no* sample // in either band — the whole point of the test. bool over(int tab) => samples.any( - (x) => x > slot * tab - slot / 2 && x < slot * tab + slot / 2); - expect(over(2), isTrue, - reason: 'the pill never passed over Earnings; samples: $samples'); - expect(over(1), isTrue, - reason: 'the pill never passed over Bookings; samples: $samples'); + (x) => x > slot * tab - slot / 2 && x < slot * tab + slot / 2, + ); + expect( + over(2), + isTrue, + reason: 'the pill never passed over Earnings; samples: $samples', + ); + expect( + over(1), + isTrue, + reason: 'the pill never passed over Bookings; samples: $samples', + ); // And it went there directly — no backtracking, which would read as a wobble. for (var i = 1; i < samples.length; i++) { - expect(samples[i], lessThanOrEqualTo(samples[i - 1] + 0.5), - reason: 'the pill moved backwards at sample $i: $samples'); + expect( + samples[i], + lessThanOrEqualTo(samples[i - 1] + 0.5), + reason: 'the pill moved backwards at sample $i: $samples', + ); } await tester.pumpAndSettle(); @@ -129,8 +143,11 @@ void main() { selected.value = 2; await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); - expect(pillLeft(tester), closeTo(midway, slot * 0.25), - reason: 'it should carry on from $midway, not jump back to ${slot * 3}'); + expect( + pillLeft(tester), + closeTo(midway, slot * 0.25), + reason: 'it should carry on from $midway, not jump back to ${slot * 3}', + ); await tester.pumpAndSettle(); expect(pillLeft(tester), closeTo(slot * 2, 0.5)); @@ -158,8 +175,11 @@ void main() { final lit = _lit(tester); if (lit[1] > 0.05 || lit[2] > 0.05) sawMiddleGlow = true; } - expect(sawMiddleGlow, isTrue, - reason: 'Bookings and Earnings should warm up under the passing pill'); + expect( + sawMiddleGlow, + isTrue, + reason: 'Bookings and Earnings should warm up under the passing pill', + ); await tester.pumpAndSettle(); expect(_lit(tester), [1.0, 0.0, 0.0, 0.0]); @@ -192,7 +212,8 @@ class _PillBar extends StatefulWidget { State<_PillBar> createState() => _PillBarState(); } -class _PillBarState extends State<_PillBar> with SingleTickerProviderStateMixin { +class _PillBarState extends State<_PillBar> + with SingleTickerProviderStateMixin { late final AnimationController _anim; late final Animation _curve; late Tween _tween; diff --git a/test/card_cta_height_test.dart b/test/card_cta_height_test.dart index da6a5f8..25175aa 100644 --- a/test/card_cta_height_test.dart +++ b/test/card_cta_height_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:miler/views/helpers/constants/app_theme.dart'; @@ -39,13 +40,15 @@ void main() { height: height, child: ElevatedButton.icon( onPressed: () {}, - icon: const Icon(Icons.arrow_forward_rounded, size: iconSize), + icon: const Icon(LucideIcons.arrowRight, size: iconSize), label: const Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontWeight: FontWeight.w800, fontSize: labelFontSize), + fontWeight: FontWeight.w800, + fontSize: labelFontSize, + ), ), style: withOverrides ? ElevatedButton.styleFrom( @@ -72,20 +75,27 @@ void main() { // 48 − (18 + 18) of inherited vertical padding leaves a 12px content band, // and everything in the button is crushed into it. The icon is the clean // witness: it asked for 19px and cannot have been drawn correctly at 12. - final content = tester.getRect(find.byIcon(Icons.arrow_forward_rounded)); - expect(content.height, lessThan(iconSize), - reason: 'this is the squeeze that cut the button label in half'); - expect(tester.getRect(find.text(label)).height, lessThan(labelFontSize), - reason: 'the label box is shorter than one line of its own text'); + final content = tester.getRect(find.byIcon(LucideIcons.arrowRight)); + expect( + content.height, + lessThan(iconSize), + reason: 'this is the squeeze that cut the button label in half', + ); + expect( + tester.getRect(find.text(label)).height, + lessThan(labelFontSize), + reason: 'the label box is shorter than one line of its own text', + ); }); - testWidgets('overriding padding and minimumSize lets the label fit', - (tester) async { + testWidgets('overriding padding and minimumSize lets the label fit', ( + tester, + ) async { await tester.pumpWidget(cta(withOverrides: true)); expect(tester.takeException(), isNull); final button = tester.getRect(find.byType(ElevatedButton)); - final icon = tester.getRect(find.byIcon(Icons.arrow_forward_rounded)); + final icon = tester.getRect(find.byIcon(LucideIcons.arrowRight)); final text = tester.getRect(find.text(label)); // The SizedBox is the single source of truth for the height. @@ -123,9 +133,11 @@ void main() { height: height, child: OutlinedButton.icon( onPressed: () {}, - icon: const Icon(Icons.close_rounded, size: 18), - label: const Text('Skip stop', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)), + icon: const Icon(LucideIcons.x, size: 18), + label: const Text( + 'Skip stop', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), style: withOverrides ? OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 14), @@ -143,8 +155,11 @@ void main() { testWidgets('squeezes the icon without the overrides', (tester) async { await tester.pumpWidget(outlined(withOverrides: false)); expect(tester.takeException(), isNull, reason: 'it fails silently'); - expect(tester.getRect(find.byType(Icon)).height, lessThan(18), - reason: 'this is the shape that shrank the icon to 16px'); + expect( + tester.getRect(find.byType(Icon)).height, + lessThan(18), + reason: 'this is the shape that shrank the icon to 16px', + ); }); testWidgets('and does not with them', (tester) async { diff --git a/test/card_density_test.dart b/test/card_density_test.dart index 48ad7f9..6a4f8fb 100644 --- a/test/card_density_test.dart +++ b/test/card_density_test.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/home/route_brief.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/Dashboard/home/trip_brief_strip.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart'; import 'package:miler/views/Dashboard/home/trip_tabs.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; @@ -49,20 +52,47 @@ void main() { // rider saw a stop. These tests hold the new budget, because the failure mode // is exactly the one that produced the old header: a line at a time, each // individually defensible. + /// Opens the trip strip's folded figures. + /// + /// They are reference material and sit behind one chevron — see + /// `states the journey figures`. Anything asserting a figure opens it first. + Future openBrief(WidgetTester tester) async { + await tester.tap(find.byType(TripBriefStrip)); + await tester.pumpAndSettle(); + } + group('Trip brief strip', () { Future briefHeight(WidgetTester tester, {double cash = 0}) async { await pump( tester, - TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: cash)])), + TripCard( + trip: Trip.fromStops( + id: 't1', + stops: [stop('A', cash: cash)], + ), + ), ); return tester.getSize(find.byType(TripBriefStrip)).height; } - testWidgets('stays compact when collapsed', (tester) async { + // ── The brief is one open card now ── + // + // It was a collapsed line with the four journey figures behind a chevron, + // and a 3pt progress rule under it. The rule sat directly below the app bar + // in the exact position and shape every other app uses for a loading + // indicator, so a completion signal was read as a spinner; and the chevron + // was opened so rarely that the figures behind it were shipped and + // unreachable. + // + // So: no rule, no disclosure, one card that is always in the state the + // rider previously had to discover. That costs height, and the budget says + // how much — enough for a head, a divider and one row of four figures, and + // not enough for a fifth thing to be added without a decision. + testWidgets('stays within the card budget', (tester) async { final h = await briefHeight(tester); expect( h, - lessThan(90), + lessThan(190), reason: 'measured $h — the brief is the permanent furniture above the ' 'route, so every point of it is charged at every scroll position', @@ -81,29 +111,54 @@ void main() { ); }); - testWidgets('holds the journey figures back until it is opened', ( + testWidgets('states the journey figures without being asked', ( tester, ) async { await pump( tester, - TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 940)])), + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 940)]), + ), ); - // The journey tiles are small-caps. ('Trip slot' was in this list until - // the row was removed — see the shift/slot test below.) - const briefLabels = [ + // ── Reversed, on an explicit product call ── + // + // This once required the four figures ABSENT until tapped; then it was + // flipped to require them present, on the argument that a once-a-shift + // read behind a chevron nobody presses is information the app has and + // the rider does not. That argument is about *discoverability* and it + // was answered by a measurement: on a 390×844 phone the four columns + // plus the trip tabs plus the app bar reach the fold before the first + // kitchen, so Home opened on a dashboard about the run instead of on + // the run. + // + // The founder's call is that the figures are reference material and the + // route is the screen. What stays out is the progress face — stops left, + // and cash in hand — which are the two a rider acts on mid-shift. + for (final label in const [ 'DURATION', 'DISTANCE', 'PARCELS', 'PAYMENT', - ]; - - // Collapsed: progress and time, nothing else. - for (final label in briefLabels) { + ]) { expect( find.text(label), findsNothing, - reason: '"$label" is briefing material — it must not be on the face', + reason: '"$label" is reference; it is one tap away, not permanent', + ); + } + + // And the face itself is still out, unasked. + expect(find.textContaining('stop'), findsWidgets); + + // One tap brings the whole set back. + await tester.tap(find.byType(TripBriefStrip)); + await tester.pumpAndSettle(); + for (final label in const ['DURATION', 'DISTANCE', 'PARCELS']) { + expect( + find.text(label), + findsOneWidget, + reason: '$label after the tap', ); } expect( @@ -111,23 +166,39 @@ void main() { findsNothing, reason: 'the mislabelled shift caption is gone with the card', ); - - final collapsed = tester.getSize(find.byType(TripBriefStrip)).height; - - await tester.tap(find.byType(TripBriefStrip)); - await tester.pumpAndSettle(); - - for (final label in briefLabels) { - expect(find.text(label), findsOneWidget, reason: '"$label" on open'); - } - expect( - tester.getSize(find.byType(TripBriefStrip)).height, - greaterThan(collapsed), - reason: 'opening the brief must actually reveal something', - ); expect(tester.takeException(), isNull); }); + // ── Nothing on this strip can be opened any more ── + testWidgets('carries exactly one disclosure control', (tester) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + ), + ); + + // One chevron, and it is the only thing on the strip that can be + // pressed. Small and grey: what is behind it is reference, and a loud + // control would advertise it as something to check. + expect( + find.descendant( + of: find.byType(TripBriefStrip), + matching: find.byIcon(LucideIcons.chevronDown), + ), + findsOneWidget, + ); + // The rule that read as a loading bar. + expect( + find.descendant( + of: find.byType(TripBriefStrip), + matching: find.byType(LinearProgressIndicator), + ), + findsNothing, + reason: 'a thin bar under the app bar reads as "still loading"', + ); + }); + testWidgets('lays out inside a scroll view, open or shut', (tester) async { // The row this replaced used `CrossAxisAlignment.stretch`, which needs an // `IntrinsicHeight` above it or the cards are asked to be infinitely tall @@ -136,12 +207,12 @@ void main() { // holds that it stays that way. await pump( tester, - TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 1310)])), + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 1310)]), + ), ); expect(tester.takeException(), isNull); - await tester.tap(find.byType(TripBriefStrip)); - await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); }); @@ -239,8 +310,10 @@ void main() { reason: '"$label" is not a per-stop control any more', ); } - // The tick is what is left, and it is how a stop is chosen. - expect(find.bySemanticsLabel('Select stop 1'), findsOneWidget); + // The tick is what is left, and it is how a stop is chosen. Ported: the + // card's tick box became the timeline row itself, which announces as a + // selection control while one is meaningful. + expect(find.bySemanticsLabel(RegExp('^Select ')), findsOneWidget); }); testWidgets('an accepted stop leaves the screen entirely', (tester) async { @@ -250,10 +323,13 @@ void main() { // work in hand, and work in hand lives on Bookings — the row used to stay // behind saying "Accepted", which is a row the rider can do nothing with. expect(find.text('Sri Balaji Stores'), findsNothing); - expect(find.bySemanticsLabel('Select stop 1'), findsNothing); - // The trip says what happened to it instead of leaving a gap. + expect(find.bySemanticsLabel(RegExp('^Select ')), findsNothing); + // The trip says what happened to it instead of leaving a gap — as an + // illustration now, whose semantic label still carries the sentence. expect( - find.text('Every stop on this trip is taken — work them from Bookings'), + find.bySemanticsLabel( + 'Every stop on this trip is taken — work them from Bookings', + ), findsOneWidget, ); }); @@ -264,9 +340,12 @@ void main() { await pump( tester, TripCard( - trip: Trip.fromStops(id: 't1', stops: [ - {...stop('A'), 'orderstatus': 'skipped'}, - ]), + trip: Trip.fromStops( + id: 't1', + stops: [ + {...stop('A'), 'orderstatus': 'skipped'}, + ], + ), onToggleSelect: (_) {}, onAcceptSelected: (_) async {}, ), @@ -278,12 +357,21 @@ void main() { expect(find.text('Sri Balaji Stores'), findsNothing); expect(find.widgetWithText(MilerButton, 'Resume'), findsNothing); expect( - find.text('Every stop on this trip is taken — work them from Bookings'), + find.bySemanticsLabel( + 'Every stop on this trip is taken — work them from Bookings', + ), findsOneWidget, ); }); - testWidgets('a rejected stop can be put back to undecided', (tester) async { + // ── A declined stop leaves Home, and the UNDO leaves with it ── + // + // It used to stay on the route struck through, with an UNDO beside it, for + // the rest of the shift — a row the rider had finished with, occupying the + // screen he uses to decide what is next. Declining is a decision, and + // decisions are recorded on Activity: it files there the moment he makes + // it, stamped `rejected` so that page can say **Declined**. + testWidgets('a declined stop leaves Home entirely', (tester) async { await pump( tester, TripCard( @@ -295,17 +383,26 @@ void main() { ), ); - expect(find.widgetWithText(MilerButton, 'Undo reject'), findsOneWidget); - // Undoing returns it to the queue; it does not accept it outright. + expect( + find.text('UNDO'), + findsNothing, + reason: + 'an action that reverses itself from the page it was performed ' + 'on is what kept the row here', + ); + expect(find.text('Sri Balaji Stores'), findsNothing); expect(find.widgetWithText(MilerButton, 'Accept'), findsNothing); }); - testWidgets('a rejected stop is stated by its chip, not a button', ( + // The chip that used to state this is gone with the row: a declined stop is + // no longer drawn on Home at all. What must never come back is the thing + // this test was really guarding — a dead button standing in for a status. + testWidgets('a declined stop is not drawn as a pressable anything', ( tester, ) async { await pumpStop(tester, rejected: {'A'}); - expect(find.text('Rejected'), findsOneWidget); + expect(find.text('Rejected'), findsNothing); expect( find.widgetWithText(MilerButton, 'Rejected'), findsNothing, @@ -339,10 +436,7 @@ void main() { expect(find.textContaining('Accept remaining'), findsNothing); expect(find.textContaining('Continue trip'), findsNothing); // It points at the one accept there is instead. - expect( - find.text('Tick the stops you want, then accept them below'), - findsOneWidget, - ); + expect(find.text('Tick stops to accept them'), findsOneWidget); }); testWidgets('and says where the action is once stops are ticked', ( @@ -357,10 +451,7 @@ void main() { onToggleSelect: (_) {}, ), ); - expect( - find.text('Accept the stops you picked from the bar below'), - findsOneWidget, - ); + expect(find.text('Accept your picked stops below'), findsOneWidget); }); }); @@ -387,9 +478,7 @@ void main() { shiftEnd: '18:00:00', ); await pump(tester, TripCard(trip: trip, brief: brief)); - - await tester.tap(find.byType(TripBriefStrip)); - await tester.pumpAndSettle(); + await openBrief(tester); expect(find.text('Shift'), findsOneWidget); expect( @@ -412,13 +501,11 @@ void main() { onViewRoute: () {}, ), ); - await tester.tap(find.byType(TripBriefStrip)); - await tester.pumpAndSettle(); expect( find.descendant( of: find.byType(TripBriefStrip), - matching: find.text('MAP'), + matching: find.text('Map'), ), findsNothing, reason: @@ -445,14 +532,14 @@ void main() { ), ); - expect(find.text('MAP'), findsOneWidget); + expect(find.text('Map'), findsOneWidget); expect( - tester.getRect(find.text('MAP')).top, + tester.getRect(find.text('Map')).top, lessThan(tester.getRect(find.text('Sri Balaji Stores')).top), reason: 'it sits on the hub row, above the first stop', ); - await tester.tap(find.text('MAP')); + await tester.tap(find.text('Map')); await tester.pumpAndSettle(); expect(opened, isTrue); }); @@ -474,45 +561,39 @@ void main() { ), ); - final label = tester.getRect(find.text('MAP')); - final chevron = tester.getRect(find.byIcon(Icons.chevron_right_rounded)); - final icon = tester.getRect(find.byIcon(Icons.map_rounded)); + final label = tester.getRect(find.text('Map')); + final icon = tester.getRect(find.byIcon(LucideIcons.map)); // Every part of the control has real height and shares a centre line // with the rest of it. - for (final part in [label, chevron, icon]) { + // Ported: the chevron went with the hub row's band. What is left is the + // same contract on the run head — every part of the control has real + // height and shares one centre line, so none of it is cropped. + for (final part in [label, icon]) { expect(part.height, greaterThan(0)); } - expect(chevron.center.dy, closeTo(label.center.dy, 1.5)); expect(icon.center.dy, closeTo(label.center.dy, 1.5)); }); - // The alignment the band was introduced for in the first place. - testWidgets('and the hub node shares its centre line with START · HUB', ( - tester, - ) async { - await pump( - tester, - TripCard( - trip: Trip.fromStops(id: 't1', stops: [stop('A')]), - onViewRoute: () {}, - ), - ); - - final node = tester.getRect(find.byIcon(Icons.warehouse_rounded).first); - final label = tester.getRect(find.text('START · HUB')); - expect(node.center.dy, closeTo(label.center.dy, 1.5)); - }); + // ── The band this held is gone ── + // + // `START · HUB` was a node and a label sharing a 36pt band, and this pinned + // their centre lines. The run opens on a one-line head now — an eyebrow, a + // rule and the map action — so there is no node to align and nothing left + // to crop. What the band was protecting is held by the test above, which + // asserts the map control is drawn whole. testWidgets('and stays hidden when there is nothing to open', ( tester, ) async { await pump( tester, - TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A')])), + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + ), ); expect( - find.text('MAP'), + find.text('Map'), findsNothing, reason: 'the action is hidden until a callback is supplied', ); @@ -527,12 +608,12 @@ void main() { ), ); expect( - find.text('MAP'), + find.text('Map'), findsOneWidget, reason: 'one map action per trip, on the row the route starts from', ); expect( - tester.getRect(find.text('MAP')).top, + tester.getRect(find.text('Map')).top, lessThan(tester.getRect(find.text('RETURN · HUB')).top), ); }); @@ -776,9 +857,16 @@ void main() { // first stop on a six-stop trip — and it is the control the rider reaches for // *before* reading the list, since "take the lot" is the common case. group('select all', () { - List> threeStops() => [stop('A'), stop('B'), stop('C')]; + List> threeStops() => [ + stop('A'), + stop('B'), + stop('C'), + ]; - Future pumpTrip(WidgetTester tester, {Set picked = const {}}) { + Future pumpTrip( + WidgetTester tester, { + Set picked = const {}, + }) { return pump( tester, TripCard( @@ -792,13 +880,22 @@ void main() { } testWidgets('sits above the route, not below it', (tester) async { - await pumpTrip(tester); + // In selection mode: the control is contextual now — it arrives with the + // first tick and leaves with the last, rather than occupying a row above + // every route all day. What is pinned here is unchanged and is the reason + // the test exists: *where* it appears when it does. + await pumpTrip(tester, picked: const {'A'}); - final selectAll = find.text('Select all 3 stops'); + final selectAll = find.byType(SelectAllAction); expect(selectAll, findsOneWidget); + expect(find.text('1 of 3 selected'), findsOneWidget); + // Ported: measured against the first stop rather than the START · HUB + // row, which went with the cards. The contract is the position — it heads + // the list it selects, so "take the lot" is reached before reading it + // rather than after scrolling past everything. expect( tester.getRect(selectAll).top, - lessThan(tester.getRect(find.text('START · HUB')).top), + lessThan(tester.getRect(find.text('Sri Balaji Stores').first).top), reason: 'it heads the list; the route starts under it', ); }); @@ -806,17 +903,20 @@ void main() { testWidgets('counts the picked stops as they are ticked', (tester) async { await pumpTrip(tester, picked: const {'A', 'B'}); expect(find.text('2 of 3 selected'), findsOneWidget); - expect(find.text('ALL'), findsOneWidget); }); + // The wording carries the state now, rather than a second `ALL`/`CLEAR` + // affordance sitting beside it. The control is one 44pt row and the row + // itself performs both gestures, so a separate word to aim at was one more + // target for a gesture already available on the whole line. testWidgets('offers a clear once everything is picked', (tester) async { await pumpTrip(tester, picked: const {'A', 'B', 'C'}); - expect(find.text('3 of 3 selected'), findsOneWidget); - expect(find.text('CLEAR'), findsOneWidget); + expect(find.text('All 3 selected · Clear'), findsOneWidget); }); - testWidgets('stays away when there is only one stop to pick', - (tester) async { + testWidgets('stays away when there is only one stop to pick', ( + tester, + ) async { await pump( tester, TripCard( @@ -836,15 +936,19 @@ void main() { // chip, and there is no bound on how many of those a shift produces. Six // stops in, the rider was scrolling past six rows he could do nothing with to // reach the one he could. They move to the Activity tab — but the *trip* - // keeps them, because its ring and its "N of M done" are counted from it. + // keeps them, because the brief's remaining count and its parcel figure are + // counted from it. group('completed stops', () { testWidgets('are not listed, but still count towards the trip', ( tester, ) async { - final trip = Trip.fromStops(id: 't1', stops: [ - {...stop('A'), 'orderstatus': 'picked'}, - stop('B'), - ]); + final trip = Trip.fromStops( + id: 't1', + stops: [ + {...stop('A'), 'orderstatus': 'picked'}, + stop('B'), + ], + ); await pump( tester, @@ -859,8 +963,43 @@ void main() { // Two stops in the trip, one row on screen. expect(trip.stopCount, 2); expect(find.text('Sri Balaji Stores'), findsOneWidget); + // And the brief still counts the finished one. - expect(find.text('1 of 2 stops done'), findsOneWidget); + // + // Asserted on the PARCEL figure rather than on the head: the head states + // what is LEFT, and "1 stop left" is what a trip of two with one done and + // a trip of one with none done both say. The parcel count can only reach + // 2 if the finished stop is still part of the trip, which is the whole + // contract here. + // The hero sets the figure and its caption as two texts — numeral at + // headline scale, words as its caption — so the claim is asserted on + // the pair rather than on one sentence. + expect( + find.descendant( + of: find.byType(TripBriefStrip), + matching: find.text('1'), + ), + findsOneWidget, + ); + expect(find.text('stop left'), findsOneWidget); + final singleStop = Trip.fromStops(id: 't2', stops: [stop('B')]); + expect( + trip.totalParcels, + greaterThan(singleStop.totalParcels), + reason: 'the collected stop still contributes its load', + ); + // The parcel figure is reference now, so it is behind the fold — but it + // must still be the *trip's* total rather than the visible one, which is + // the contract this test exists for. + await openBrief(tester); + expect( + find.descendant( + of: find.byType(TripBriefStrip), + matching: find.text('${trip.totalParcels}'), + ), + findsOneWidget, + reason: 'and the card states that total, not the visible one', + ); }); testWidgets('the numbering does not renumber as stops finish', ( @@ -871,41 +1010,43 @@ void main() { await pump( tester, TripCard( - trip: Trip.fromStops(id: 't1', stops: [ - {...stop('A'), 'orderstatus': 'picked'}, - stop('B'), - ]), + trip: Trip.fromStops( + id: 't1', + stops: [ + {...stop('A'), 'orderstatus': 'picked'}, + stop('B'), + ], + ), acceptedIds: const {'A'}, onToggleSelect: (_) {}, onAcceptSelected: (_) async {}, ), ); - expect(find.text('2'), findsOneWidget); - expect(find.text('1'), findsNothing); + // The position sits on the timeline node's own meta line now, rather + // than on the card's facts line — but it is still the trip's own index + // and not the visible list's, which is the whole contract. + expect(find.textContaining('Stop 2'), findsOneWidget); + expect(find.textContaining('Stop 1'), findsNothing); }); }); group('page gutter', () { - const homeGutter = 28.0; - const stopRowInset = 32.0; - - /// The rounded surface a piece of text sits on: walk up from the text to the - /// nearest `Container` that actually paints a card. - Rect cardBehind(WidgetTester tester, String anchor) { - final ancestors = find - .ancestor(of: find.text(anchor), matching: find.byType(Container)) - .evaluate(); - for (final e in ancestors) { - final d = (e.widget as Container).decoration; - if (d is BoxDecoration && d.borderRadius != null) { - final box = e.renderObject as RenderBox; - final pos = box.localToGlobal(Offset.zero); - return Rect.fromLTWH(pos.dx, pos.dy, box.size.width, box.size.height); - } - } - fail('no card found behind "$anchor"'); - } + // ── Both numbers moved when Home's sections got a surface ── + // + // These were 28 and 32 — the brief's own 12 and the timeline's own 16, + // each plus [TripCard]'s outer 16 — and they disagreed with each other by + // four points, which is what the old comment below was written without + // measuring. + // + // The brief and the route now stand on [MilerPanel], layer 1 of the + // surface ladder, because Home's ground is the canvas and nothing white + // was standing on it. The panel owns the inset: `panelGutter` 12 outside + // the white plus `panelPad` 14 inside it, and `TripCard` no longer adds a + // gutter of its own. So both land on 26, and — the part that matters — + // they land on the *same* number. `home_gutter_test` holds them together. + const homeGutter = 26.0; + const stopRowInset = 26.0; testWidgets('the summary cards and the stop rows each keep their inset', ( tester, @@ -919,50 +1060,63 @@ void main() { final screen = tester.getSize(find.byType(TripCard)).width; - // The summary is one surface now rather than four, so there is one card - // to measure instead of a list of captions — but it still has to land on - // the same gutter the footer bar uses. Found by its own decoration rather - // than by a caption, because every string on the face is data that moves - // with the trip. - final surface = find.descendant( + // ── Measured on the content, because the card is gone again ── + // + // This has now measured both, because the brief has been both. It was a + // bare line on the page, then a bordered surface, and it is a bare + // composition again: a `pureSurface` fill inside a `borderSubtle` + // outline is a four-percent step on a `daylightSurface` page, so the box + // separated nothing and cost the screen its one remaining container. + // + // Whichever it is, the contract is the same and is the reason the test + // exists: **the summary begins and ends on Home's gutter.** With no box + // to measure, the rule between the headline and the four figures is the + // one element that spans the brief's full content width, so it is what + // the gutter is read off. + // The rule lives inside the folded figures, so it is opened first — the + // gutter it measures is the same either way. + await openBrief(tester); + final rule = find.descendant( of: find.byType(TripBriefStrip), - matching: find.byWidgetPredicate( - (w) => - w is Container && - w.decoration is BoxDecoration && - (w.decoration as BoxDecoration).borderRadius != null, - ), + matching: find.byType(Divider), ); - final right = screen - tester.getRect(surface.first).right; + final r = tester.getRect(rule.first); expect( - right, + r.left, closeTo(homeGutter, 0.5), reason: - "the brief ends $right from the edge, but Home's gutter is " + 'the brief starts ${r.left} from the edge, but Home\'s gutter is ' '$homeGutter — the summary must sit on it', ); - - // The stop rows are inset further, on purpose: the rail takes the first - // 30 points on their left, so matching the gutter on the right left them - // visibly lopsided inside their own row. They are indented list items - // under the summary, not more cards in the same column. - final stopCard = cardBehind(tester, 'Sri Balaji Stores'); - final stopRight = screen - stopCard.right; expect( - stopRight, - closeTo(stopRowInset, 0.5), + screen - r.right, + closeTo(homeGutter, 0.5), reason: - 'the stop card ends $stopRight from the edge; it should be ' - 'inset to $stopRowInset rather than share the summary gutter', + 'the brief ends ${screen - r.right} from the edge, but Home\'s ' + 'gutter is $homeGutter — the summary must sit on it', ); + + // ── Ported to the timeline ── + // + // There is no card behind a stop name any more, so there is no second + // surface to measure. What survives is the half that was always the point: + // the route block is inset one step further than the summary above it, + // because a rail plus its rows is an indented list item under the brief + // rather than another card in the same column. + final page = tester.getRect(find.byType(TripCard)); + final name = tester.getRect(find.text('Sri Balaji Stores').first); expect( - stopRight, + name.left - page.left, greaterThan(homeGutter), - reason: 'the point of the inset is that it clears the border more', + reason: + 'the stop name starts ${name.left - page.left} in, which must clear ' + "Home's $homeGutter gutter — the rail takes the space before it", ); }); - testWidgets('the rail is inset as far as the card edge', (tester) async { + testWidgets('the stop card is inset the same on both sides', ( + tester, + ) async { await pump( tester, TripCard( @@ -970,20 +1124,690 @@ void main() { ), ); - // The route block indents as a whole: whatever the card's right edge - // clears, the rail's left edge clears too. Insetting one side only left - // the rail closer to the border than anything else on screen, so the row - // still read as pushed against the edge — just from the other side. - final node = tester.getRect(find.byType(TripCard)); - final railLeft = - tester.getRect(find.text('1')).center.dx - - 11; // half the 22pt node, back to its left edge + // The route block indents as a whole. The journey thread is a rail again + // — outside the rows rather than inside a card — so what is measured is + // the block's own edges: the rail starts on the route inset, and the + // block ends on it. + final page = tester.getRect(find.byType(TripCard)); + final timeline = tester.getRect(find.byType(RouteTimeline)); + expect( - railLeft - node.left, + timeline.left - page.left, closeTo(stopRowInset, 1.5), reason: - 'the rail starts ${railLeft - node.left} in; the card ends ' - '$stopRowInset in — an indented block indents on both sides', + 'the run starts ${timeline.left - page.left} in, not $stopRowInset', + ); + expect( + timeline.left - page.left, + closeTo(page.right - timeline.right, 0.5), + reason: 'an indented block indents by the same amount on both sides', + ); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // THE PICKUP NODE + // + // The node carries what the old screen said across a glass "NEXT ACTION" + // card, a heading, three meta rows and two buttons. These hold the three + // things that made that possible, because each of them is one careless + // patch away from coming back. + // ═══════════════════════════════════════════════════════════════════════ + group('the pickup node', () { + List> kitchen({bool coords = true}) => [ + for (var i = 0; i < 5; i++) + { + 'orderid': 'K1-$i', + 'type': 'delivery', + 'pickupcustomer': 'Customer $i', + 'pickupaddress': '12, SNS Colony, Peelamedu, Coimbatore', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'Quantity': 1, + 'orderstatus': 'new', + if (coords) ...{'pickuplat': 11.04, 'pickuplon': 76.98}, + }, + ]; + + Future pumpKitchen( + WidgetTester tester, { + bool coords = true, + bool rider = true, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + final stops = kitchen(coords: coords); + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't', stops: stops), + acceptedIds: {for (final s in stops) s['orderid'] as String}, + riderLat: rider ? 11.0168 : null, + riderLng: rider ? 76.9558 : null, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onNavigateStop: (_) {}, + onStopTap: (_) {}, + ), + ); + } + + // ── One journey action on the whole screen ── + // + // There were two: a full-width red button on the "NEXT ACTION" glass card + // above the route, and an identical one inside the group for the same + // counter ~200pt below it. The card is gone and the node that owns the + // journey owns the button — so this counts, rather than trusting it. + testWidgets('offers exactly one Navigate, for the place that owns it', ( + tester, + ) async { + await pumpKitchen(tester); + // The journey is an icon in the card's action cluster now — the three + // things a rider does about a place, as three equal circles. Asserted by + // its accessible name, which is the contract that actually matters and + // does not move when the glyph does. + expect(find.bySemanticsLabel('Navigate to this pickup'), findsOneWidget); + expect(find.text('Vidhya Kitchen'), findsOneWidget); + }); + + // ── Distance and ETA are measured, or absent ── + // + // The pair is the reason the node has a second column at all, and the one + // way it could do real harm is by being confident when it does not know: + // a rider plans the next fifteen minutes around it. `metersToStop` returns + // null rather than zero when there is no fix and no `kms`, and the column + // must not invent a `0 m` or a `~0 min` out of that. + testWidgets('states the journey when it is measured', (tester) async { + await pumpKitchen(tester); + expect(find.textContaining(' km'), findsOneWidget); + expect(find.textContaining('~'), findsOneWidget); + }); + + testWidgets('and says nothing at all when it is not', (tester) async { + await pumpKitchen(tester, coords: false, rider: false); + expect( + find.textContaining(' km'), + findsNothing, + reason: 'no coordinates and no rider fix means no distance to state', + ); + expect(find.textContaining('0 m'), findsNothing); + // The load still reads, so the node degrades rather than emptying. + expect(find.text('5 bags'), findsOneWidget); + }); + + // ── The destination never truncates first ── + // + // The journey column was a `Flexible` beside the identity column's + // `Expanded`, so the two split the row evenly and "Vidhya Kitchen" at 19sp + // rendered as "Vid…" — the one element on the node that must survive, + // ellipsised by a metric that had width to spare. + testWidgets('gives the name the room the journey does not need', ( + tester, + ) async { + await pumpKitchen(tester); + + final name = tester.getRect(find.text('Vidhya Kitchen')); + final journey = tester.getRect(find.textContaining(' km')); + expect( + name.width, + greaterThan(journey.width), + reason: + 'the destination gets ${name.width} and the journey ' + '${journey.width} — the name is the dominant element', + ); + }); + + // ── One place open at a time ── + // + // Groups used to hold independent open/shut state, so every card the rider + // opened stayed open — three kitchens expanded is ~600pt of orders, and the + // question the screen exists to answer is buried under two counters he is + // not standing at. The underlying fact is an accordion: he is at one place. + testWidgets('opening a place closes the one that was open', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map order(String id, String kitchen, String customer) => + { + 'orderid': id, + 'type': 'delivery', + 'pickupcustomer': customer, + 'pickupaddress': '12, SNS Colony, Peelamedu', + 'kitchenid': kitchen, + 'kitchenname': kitchen, + 'Quantity': 1, + 'orderstatus': 'new', + }; + + await pump( + tester, + TripCard( + trip: Trip.fromStops( + id: 't', + stops: [ + order('1', 'Vidhya Kitchen', 'Joe'), + order('2', 'Vidhya Kitchen', 'Arun'), + order('3', 'Anna Kitchen', 'Meena'), + order('4', 'Anna Kitchen', 'Sai'), + ], + ), + onToggleSelect: (_) {}, + onStopTap: (_) {}, + ), + ); + + // ── Measured, not found ── + // + // `AnimatedCrossFade` builds *both* branches, so `find.text('Meena')` + // resolves inside the collapsed card's unpainted child and cannot tell + // open from shut. Height can: an open place is its header plus a panel of + // orders, a shut one is just the header. + double heightOf(int i) => + tester.getSize(find.byType(PickupTimelineGroup).at(i)).height; + + // The first place opens itself — the rider has not chosen anything yet. + final firstOpen = heightOf(0); + final secondShut = heightOf(1); + expect( + firstOpen, + greaterThan(secondShut), + reason: 'the group being worked opens without being asked', + ); + + await tester.tap(find.text('Anna Kitchen')); + await tester.pumpAndSettle(); + + expect(heightOf(1), greaterThan(secondShut), reason: 'the second opened'); + expect( + heightOf(0), + lessThan(firstOpen), + reason: 'and the first closed — two open places is not a route', + ); + + // Tapping the open one shuts it, leaving the run fully collapsed. + await tester.tap(find.text('Anna Kitchen')); + await tester.pumpAndSettle(); + expect(heightOf(0), lessThan(firstOpen)); + expect(heightOf(1), closeTo(secondShut, 0.5)); + }); + + // ── Three targets, three meanings ── + // + // The card carries three gestures and they must not bleed into each other: + // the body previews the place, the chevron discloses the orders, and the + // rail ticks one. This is the kind of thing that regresses silently — a + // handler moved up one widget and suddenly expanding a kitchen also selects + // it — so each is asserted by what it *calls*. + testWidgets('the card body discloses, the "i" previews', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + var previews = 0; + final selected = []; + final stops = [ + for (var i = 0; i < 3; i++) + { + 'orderid': 'K1-$i', + 'type': 'delivery', + 'pickupcustomer': 'Customer $i', + 'pickupaddress': '12, SNS Colony', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'Quantity': 1, + 'orderstatus': 'new', + }, + ]; + + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't', stops: stops), + onToggleSelect: selected.add, + onStopTap: (_) {}, + onPreviewGroup: (_) => previews++, + ), + ); + + final card = find.byType(PickupTimelineGroup).first; + final open = tester.getSize(card).height; + + // The body: discloses, and never ticks. A rider must be able to look at a + // counter's orders without taking one of them on. + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + expect( + tester.getSize(card).height, + lessThan(open), + reason: 'tapping the card closed the orders', + ); + expect( + selected, + isEmpty, + reason: 'disclosing a place must never tick one of its orders', + ); + expect(previews, 0, reason: 'and it does not open a sheet either'); + + // The place circle: previews, and leaves the disclosure alone. (The + // glyph is the storefront now — the app's mark for "this place" — where + // it was a generic "i".) + final shut = tester.getSize(card).height; + await tester.tap(find.byIcon(LucideIcons.store)); + await tester.pumpAndSettle(); + expect(previews, 1, reason: 'the place circle opens the pickup preview'); + expect( + tester.getSize(card).height, + closeTo(shut, 0.5), + reason: 'and it must not toggle the list', + ); + }); + + // ── The disclosure has to LOOK like one ── + // + // The chevron was removed once when the header became the toggle, and its + // signal went with it: a collapsed kitchen showed nothing that said "there + // is a list under me". It is back as a passive indicator inside the + // header's own gesture — one arena, one owner — and this holds the three + // facts that make it honest: it exists, it turns with the state, and a + // flat stop (whose tap navigates instead) wears the pointing variant. + testWidgets('the header wears a chevron that turns with the state', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + final stops = [ + for (var i = 0; i < 2; i++) + { + 'orderid': 'K1-$i', + 'type': 'delivery', + 'pickupcustomer': 'Customer $i', + 'pickupaddress': '12, SNS Colony', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'Quantity': 1, + 'orderstatus': 'new', + }, + ]; + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't', stops: stops), + onToggleSelect: (_) {}, + onStopTap: (_) {}, + ), + ); + + // Scoped to the route. The trip brief above it also folds, and since + // Home moved to one icon family both disclosure marks are the same + // glyph — correctly, they mean the same thing — so a bare `byIcon` here + // matches the brief's chevron as well and stops being a statement about + // the group header at all. + final chevron = find.descendant( + of: find.byType(RouteTimeline), + matching: find.byIcon(LucideIcons.chevronDown), + ); + expect(chevron, findsOneWidget); + + double turns() => tester + .widget( + find.ancestor(of: chevron, matching: find.byType(AnimatedRotation)), + ) + .turns; + + final openTurns = turns(); + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + expect( + turns(), + isNot(openTurns), + reason: + 'the chevron must turn when the list opens or closes — a ' + 'static disclosure mark is a printed one', + ); + }); + + testWidgets('a flat stop points instead of folding', (tester) async { + // One order, no counter: its header pushes the stop detail, so the mark + // is the go-somewhere chevron, not the fold-open one. + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't', stops: [stop('A')]), + onStopTap: (_) {}, + ), + ); + // Scoped for the same reason as above: the trip brief's own fold mark + // is now the same glyph, so an unscoped `findsNothing` here is a claim + // about the whole card rather than about this stop's header. + expect(find.byIcon(LucideIcons.chevronRight), findsWidgets); + expect( + find.descendant( + of: find.byType(RouteTimeline), + matching: find.byIcon(LucideIcons.chevronDown), + ), + findsNothing, + ); + }); + + // ── A card must not change height when its state changes ── + // + // The state word had a line of its own between the name and the load, and + // that line only existed when there *was* a state — so an accepted card + // stood ~18pt taller than a pending one and a three-kitchen run stepped up + // and down as the rider worked it. Controls that move under the thumb + // between one glance and the next are the thing this guards. + // + // The word sits on the action row now, in space that row already spent on a + // `Spacer`, so accepting a counter changes what the card *says* and nothing + // about where anything on it is. + testWidgets('accepting a place does not resize its card', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Future heightWith(Set accepted) async { + final stops = kitchen(); + await pump( + tester, + TripCard( + // A fresh subtree per pump. Without it the second `pumpWidget` + // reuses `_RouteTimelineState` — including the accordion key the + // first one's tap left behind — so the second tap *opened* the card + // instead of closing it and the measurement compared open to shut. + key: ValueKey('accepted-${accepted.length}'), + trip: Trip.fromStops(id: 't', stops: stops), + acceptedIds: accepted, + riderLat: 11.0168, + riderLng: 76.9558, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onCallStop: (_) {}, + onNavigateStop: (_) {}, + onStopTap: (_) {}, + onPreviewGroup: (_) {}, + ), + ); + // Collapsed, so what is measured is the *card* rather than its + // contents: an accepted place also gains a `Navigate to pickup` inside + // its dropdown, and that is a control appearing, which is allowed to + // cost height. What is not allowed is the header changing shape. + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + return tester.getSize(find.byType(PickupTimelineGroup).first).height; + } + + final pending = await heightWith(const {}); + final accepted = await heightWith({for (var i = 0; i < 5; i++) 'K1-$i'}); + + expect( + accepted, + closeTo(pending, 0.5), + reason: + 'pending $pending vs accepted $accepted — the state word must not ' + 'be worth a line of its own', + ); + // And it is genuinely said, not simply dropped — with how much of the + // counter he has taken, which "Accepted" alone never told him. A place + // with four orders and one accepted used to render the same word as a + // place with four accepted. + expect(find.text('Accepted 5/5'), findsOneWidget); + }); + + // ── Two of four accepted has to be readable in under a second ── + // + // A counter with four orders where the rider had taken two drew four + // identical hollow numbered rings: the one question an open group has to + // answer — *which of these are already mine* — had no answer on the screen, + // and he had to remember. + // + // Three channels now carry it, so losing any one still leaves two: the + // node is **filled** rather than hollow (shape — survives grayscale and + // sunlight), the tile takes a settled wash, and the name goes to w700 in + // full slate. The bag numeral survives all of it, because that is what the + // rider matches against the shelf. + testWidgets('an accepted order is unmistakable beside a pending one', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: kitchen()), + // Two of the five taken; three still to decide. + acceptedIds: const {'K1-0', 'K1-1'}, + riderLat: 11.0168, + riderLng: 76.9558, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onCallStop: (_) {}, + onNavigateStop: (_) {}, + onStopTap: (_) {}, + onPreviewGroup: (_) {}, + ), + ); + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + + final steps = tester + .widgetList(find.byType(PickupTimelineStep)) + .toList(); + expect(steps, hasLength(5)); + expect(steps[0].state, StopState.accepted); + expect(steps[4].state, StopState.pending); + + // ── Channel 1: the node is filled, not hollow ── + final nodes = tester + .widgetList(find.byType(AnimatedContainer)) + .map((c) => c.decoration) + .whereType() + .where((d) => d.shape == BoxShape.circle) + .toList(); + final filled = nodes.where((d) => d.color != ColorConstants.pureSurface); + expect( + filled.length, + greaterThanOrEqualTo(2), + reason: 'the two accepted orders draw a filled mark', + ); + + // ── Channel 2: the bag numeral survives ── + // + // One order = one bag, and the numeral is matched against a printed + // label. Accepting must never renumber it or replace it with a tick. + for (var i = 1; i <= 5; i++) { + expect( + find.text('$i'), + findsWidgets, + reason: 'bag $i lost its number when its state changed', + ); + } + + // ── Channel 3: the group says how far through the counter he is ── + expect(find.text('Accepted 2/5'), findsOneWidget); + }); + + // ── Two intents, two targets ── + // + // The row body ticked and a small block in the corner opened the sheet, so + // the *content* of a row — a customer's name — did the one thing that has + // nothing to do with reading about that customer, and a rider who wanted + // the address had to find the corner. Tapping content opens content; + // selection keeps its own dedicated control, the rail node, which is the + // thing that visibly changes when ticked. + // ── The body selects; the corner opens ── + // + // Selecting is what a rider does to an order five times per counter; + // reading its address is what he does to one of them occasionally. The + // frequent action gets the large target — the whole row — and the + // occasional one gets the block that already looks like a way in: the bag, + // the order id and a chevron in the right corner. + // + // The two must stay **siblings**. Nested, the two recognisers resolve to + // neither, which is how the card's chevron came to be dead for a build. + testWidgets('the row body selects; the order-id block opens it', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + final opened = []; + final ticked = []; + + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: kitchen()), + acceptedIds: const {'K1-0'}, + riderLat: 11.0168, + riderLng: 76.9558, + onToggleSelect: ticked.add, + onSelectAll: (_) {}, + onCallStop: (_) {}, + onNavigateStop: (_) {}, + onStopTap: (s) => opened.add((s['orderid'] ?? '').toString()), + onPreviewGroup: (_) {}, + ), + ); + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + + final step = tester.widget( + find.byType(PickupTimelineStep).first, + ); + expect(step.selectable, isTrue, reason: 'a meal row stays tickable'); + + // ── The row body ticks ── + // + // Invoked rather than tapped: the panel lives inside an + // `AnimatedCrossFade`, whose Stack makes a synthetic tap land on the + // card's ink host rather than the row. What is under test is the wiring, + // and the wiring is what a real pointer resolves against. + final rowInk = tester.widget( + find + .descendant( + of: find.byType(PickupTimelineStep).first, + matching: find.byType(InkWell), + ) + .first, + ); + rowInk.onTap!(); + await tester.pumpAndSettle(); + + expect(ticked, ['K1-0'], reason: 'the row is the selection target'); + expect(opened, isEmpty, reason: 'selecting must not open a sheet'); + + // ── The corner block opens ── + expect( + find.byIcon(LucideIcons.chevronRight), + findsWidgets, + reason: 'the way in still looks like one', + ); + step.onOpen!(); + await tester.pumpAndSettle(); + expect(opened, ['K1-0']); + expect(ticked, ['K1-0'], reason: 'opening must not change the selection'); + }); + + // ── The spine starts at the first order and ends at the last ── + // + // `lineAbove` was hard-coded true, so order 1 drew a connector upward out + // of its own node into the select-all row above it — a length of route + // joining a stop to a control. A connector means "there is another stop + // that way"; above the first one there is not. + testWidgets('the rail opens at order 1 and closes at the last', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: kitchen()), + acceptedIds: {for (var i = 0; i < 5; i++) 'K1-$i'}, + riderLat: 11.0168, + riderLng: 76.9558, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onCallStop: (_) {}, + onNavigateStop: (_) {}, + onStopTap: (_) {}, + onPreviewGroup: (_) {}, + ), + ); + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + + final steps = tester + .widgetList(find.byType(PickupTimelineStep)) + .toList(); + expect(steps, hasLength(5)); + + expect(steps.first.first, isTrue, reason: 'nothing is above order 1'); + expect(steps.first.last, isFalse); + expect(steps.last.last, isTrue, reason: 'nothing is below the last'); + expect(steps.last.first, isFalse); + for (final mid in steps.sublist(1, steps.length - 1)) { + expect(mid.first, isFalse); + expect(mid.last, isFalse); + } + }); + + // ── One accepted out of four is not the same as four ── + // + // The chip said "Accepted" either way, so a rider who had taken a quarter + // of a counter was told only that he had taken *something* — and the three + // still to decide were a scroll and a count away, which is the work this + // word exists to remove. + testWidgets('the state word counts how much of the place he took', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: kitchen()), + acceptedIds: const {'K1-0'}, + riderLat: 11.0168, + riderLng: 76.9558, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onCallStop: (_) {}, + onNavigateStop: (_) {}, + onStopTap: (_) {}, + onPreviewGroup: (_) {}, + ), + ); + + expect(find.text('Accepted 1/5'), findsOneWidget); + }); + + // ── Density, as a number, and the budget moved once ── + // + // This was ≤48: a step was one line of text plus its air. A step now carries + // two — the bag and the order's own id, which is the block the rider taps to + // open a stop — inside a tile with its own inset. That is ~58, and it was + // bought deliberately: the id is how the rider and the hub name an order on + // the phone when something has gone wrong, and it had no home before. + // + // The guard is what matters and it still guards. A step that grew back into + // a *card* — address lines, a facts row, a button — was 138pt, which is what + // made a five-order counter not fit a viewport. 64 leaves no room for that + // and every point of headroom has to be argued for here. + testWidgets('keeps a step to its two lines plus its air', (tester) async { + await pumpKitchen(tester); + final h = tester.getSize(find.byType(PickupTimelineStep).first).height; + expect( + h, + lessThanOrEqualTo(64.0), + reason: 'a step measured $h — it is a milestone, not a card', + ); + expect( + h, + greaterThanOrEqualTo(44.0), + reason: 'and it still has to be hittable with a thumb', ); }); }); diff --git a/test/card_stripe_layout_test.dart b/test/card_stripe_layout_test.dart index 0daf505..3a4ee71 100644 --- a/test/card_stripe_layout_test.dart +++ b/test/card_stripe_layout_test.dart @@ -17,10 +17,8 @@ import 'package:flutter_test/flutter_test.dart'; /// imported, so these tests pin the idiom rather than the widget. void main() { Widget inList(Widget child) => MaterialApp( - home: Scaffold( - body: ListView(children: [child]), - ), - ); + home: Scaffold(body: ListView(children: [child])), + ); Widget stripeRow({required bool withIntrinsicHeight}) { final row = Row( @@ -44,15 +42,20 @@ void main() { ); } - testWidgets('a stretch Row with a height-less child breaks in a ListView', - (tester) async { + testWidgets('a stretch Row with a height-less child breaks in a ListView', ( + tester, + ) async { await tester.pumpWidget(inList(stripeRow(withIntrinsicHeight: false))); - expect(tester.takeException(), isNotNull, - reason: 'this is the shape that made every stop card disappear'); + expect( + tester.takeException(), + isNotNull, + reason: 'this is the shape that made every stop card disappear', + ); }); - testWidgets('IntrinsicHeight gives stretch something finite to stretch to', - (tester) async { + testWidgets('IntrinsicHeight gives stretch something finite to stretch to', ( + tester, + ) async { await tester.pumpWidget(inList(stripeRow(withIntrinsicHeight: true))); expect(tester.takeException(), isNull); @@ -63,10 +66,12 @@ void main() { // The stripe spans the full card height rather than collapsing. final stripe = tester.getSize( - find.descendant( - of: find.byType(IntrinsicHeight), - matching: find.byType(Container), - ).first, + find + .descendant( + of: find.byType(IntrinsicHeight), + matching: find.byType(Container), + ) + .first, ); expect(stripe.height, size.height); expect(stripe.width, 4); diff --git a/test/collect_payment_test.dart b/test/collect_payment_test.dart index 867ae4a..edd6fc7 100644 --- a/test/collect_payment_test.dart +++ b/test/collect_payment_test.dart @@ -32,8 +32,9 @@ void main() { designSize: const Size(390, 844), builder: (_, _) => MaterialApp( builder: (context, child) => MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), child: child!, ), home: CollectPaymentPage( @@ -64,8 +65,9 @@ void main() { if (pickupAmt > 0) 'pickupamt': '$pickupAmt', }; - testWidgets('the amount is the booking\'s, and it names who owes it', - (tester) async { + testWidgets('the amount is the booking\'s, and it names who owes it', ( + tester, + ) async { await pump(tester, stopOf(due: 450)); expect(find.text('₹450'), findsOneWidget); @@ -96,14 +98,12 @@ void main() { await pump(tester, stopOf()); expect(find.byType(QrImageView), findsNothing); - expect( - find.textContaining('Slide — took ₹450 by Cash'), - findsOneWidget, - ); + expect(find.textContaining('Slide — took ₹450 by Cash'), findsOneWidget); }); - testWidgets('choosing UPI puts the QR on the page, not behind a dialog', - (tester) async { + testWidgets('choosing UPI puts the QR on the page, not behind a dialog', ( + tester, + ) async { await pump(tester, stopOf(due: 450)); expect(find.byType(QrImageView), findsNothing); @@ -173,8 +173,9 @@ void main() { expect(find.text('₹50 still owed on this consignment'), findsOneWidget); }); - testWidgets('a separate pickup charge is listed, never added up', - (tester) async { + testWidgets('a separate pickup charge is listed, never added up', ( + tester, + ) async { // The office sets what is due. The page explains it; it does not do // arithmetic the office has not sanctioned. await pump(tester, stopOf(due: 450, pickupAmt: 60)); diff --git a/test/collected_by_miler_test.dart b/test/collected_by_miler_test.dart new file mode 100644 index 0000000..4f684b6 --- /dev/null +++ b/test/collected_by_miler_test.dart @@ -0,0 +1,424 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/api_status.dart'; +import 'package:miler/data/consignment_state.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RUNG BETWEEN THE COUNTER AND THE ROAD +/// +/// Backend contract change, 21 Aug 2026. Until then `pickup-complete` pushed a +/// hyperlocal booking straight to `Out_for_Delivery`, so the hub's board said +/// *actively delivering* for food still on the kitchen counter, and the app +/// had nowhere honest to put the one fact only the rider knows — that he has +/// set off. **Start round** could therefore only ever be a note to self. +/// +/// The pivot now stops at `Collected_By_Miler` and +/// `POST /miler/consignments/:id/start-delivery` makes the release, so the +/// press is a real write again. These pin the four pieces that has to hold: +/// the state exists and is not mistaken for a hub hold, the gate routes it to +/// the rider rather than blaming the warehouse, the release is asked of the +/// server before anything local is written, and a list row's consignment +/// status outranks the booking status that stopped being informative at +/// conversion. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List sent; + + /// Answers every request with [body], and records what was asked. + void stub({ + Map body = const {'success': true}, + int code = 200, + }) { + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + return http.Response( + json.encode(body), + code, + headers: const {'content-type': 'application/json'}, + ); + }); + } + + /// Answers each path from [routes], falling back to a bare success. + void stubRoutes(Map routes) { + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + for (final entry in routes.entries) { + if (req.url.path.endsWith(entry.key)) return entry.value; + } + return http.Response( + json.encode({'success': true}), + 200, + headers: const {'content-type': 'application/json'}, + ); + }); + } + + http.Response jsonRes(Object body, [int code = 200]) => http.Response( + json.encode(body), + code, + headers: const {'content-type': 'application/json'}, + ); + + setUp(() async { + MockBackend.enabled = false; + SharedPreferences.setMockInitialValues({'authtoken': 'test-token'}); + await ApiConfig.setToken('test-token'); + stub(); + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + }); + + group('the state itself', () { + test('Collected_By_Miler parses, however it is spelled', () { + for (final raw in [ + 'Collected_By_Miler', + 'collected_by_miler', + 'COLLECTED BY MILER', + ]) { + expect( + consignmentStateFromRaw(raw), + ConsignmentState.collectedByMiler, + reason: + '$raw must not fall through to unknown — an unrecognised ' + 'state blocks the door', + ); + } + }); + + test('it is the rider holding the parcel, not the hub', () { + const state = ConsignmentState.collectedByMiler; + expect( + state.awaitsHub, + isFalse, + reason: + 'telling a rider the hub has the parcel in his own box is the ' + 'exact error this state exists to prevent', + ); + expect(state.needsRelease, isTrue); + expect( + state.isDeliverable, + isFalse, + reason: 'deliver is refused until start-delivery has run', + ); + expect(state.isClosed, isFalse); + }); + + test('a skip is reportable the moment the parcel is collected', () { + // Widened server-side on 21 Aug 2026: a customer who is not home is not + // home whether or not the rider remembered to press Start round. + expect(ConsignmentState.collectedByMiler.canSkip, isTrue); + expect(ConsignmentState.outForDelivery.canSkip, isTrue); + expect(ConsignmentState.delivered.canSkip, isFalse); + expect(ConsignmentState.inwardedAtHub.canSkip, isFalse); + }); + + test('the gate sends the rider to Start round, not to the hub', () { + expect( + ConsignmentGate.gateFor(ConsignmentState.collectedByMiler), + DeliverGate.needsRelease, + ); + expect( + ConsignmentGate.gateFor(ConsignmentState.inwardedAtHub), + DeliverGate.awaitingHub, + reason: 'the genuine hub guard must survive this change', + ); + expect( + ConsignmentGate.gateFor(ConsignmentState.delivered), + DeliverGate.alreadyDelivered, + ); + expect( + ConsignmentGate.gateFor(ConsignmentState.outForDelivery), + DeliverGate.deliverable, + ); + }); + }); + + group('reading the state', () { + test('one call answers it, where it used to take a history', () async { + stubRoutes({ + '/miler/consignments/34': jsonRes({ + 'success': true, + 'data': {'consignmentid': 34, 'status': 'Collected_By_Miler'}, + }), + }); + + expect( + await ConsignmentGate.stateOf(34), + ConsignmentState.collectedByMiler, + ); + expect(sent.single.method, 'GET'); + expect( + sent.single.url.path.endsWith('/miler/consignments/34'), + isTrue, + reason: 'the logs walk was one round trip and a sort per stop', + ); + }); + + test( + 'the flags answer it when the word is one this build cannot read', + () async { + stubRoutes({ + '/miler/consignments/34': jsonRes({ + 'success': true, + 'data': { + 'status': 'Handed_To_Locker_Bank', + 'collected': false, + 'out_for_delivery': false, + 'delivered': true, + }, + }), + }); + + expect( + await ConsignmentGate.stateOf(34), + ConsignmentState.delivered, + reason: + 'delivered is read first: mistaking a finished delivery for an ' + 'open one makes a rider post work he has already done', + ); + }, + ); + + test('an old deployment still answers, through the history', () async { + // A rider mid-round on this build meeting a server that has not shipped + // the route yet must not be stranded at a door. + stubRoutes({ + '/miler/consignments/logs/34': jsonRes([ + {'historyid': 2, 'eventstatus': 'Out_for_Delivery'}, + {'historyid': 1, 'eventstatus': 'Created'}, + ]), + '/miler/consignments/34': jsonRes({ + 'success': false, + 'message': 'not found', + }, 404), + }); + + expect( + await ConsignmentGate.stateOf(34), + ConsignmentState.outForDelivery, + ); + expect( + sent.map((r) => r.url.path.endsWith('/miler/consignments/logs/34')), + contains(true), + ); + }); + + test('a read that fails is not evidence of anything', () async { + stub(body: const {'success': false, 'message': 'gateway'}, code: 502); + expect(await ConsignmentGate.stateOf(34), ConsignmentState.unknown); + expect( + ConsignmentGate.gateFor(ConsignmentState.unknown), + DeliverGate.unknown, + reason: 'blocking on a timed-out GET strands a rider at a door', + ); + }); + + test('nothing is asked about a consignment that does not exist', () async { + expect(await ConsignmentGate.stateOf(''), ConsignmentState.unknown); + expect(await ConsignmentGate.stateOf('0'), ConsignmentState.unknown); + expect(sent, isEmpty); + }); + }); + + group('the release', () { + test('start-delivery posts against the consignment', () async { + await MilerApi.startDelivery(34); + expect(sent.single.method, 'POST'); + expect( + sent.single.url.path.endsWith('/miler/consignments/34/start-delivery'), + isTrue, + ); + }); + + test('it carries an idempotency key, stable across a retry', () async { + await MilerApi.startDelivery(34); + final first = sent.single.headers['Idempotency-Key']; + stub(); + await MilerApi.startDelivery(34); + expect(first, isNotNull); + expect( + sent.single.headers['Idempotency-Key'], + first, + reason: + 'a key that changes per press is not idempotency — a dropped ' + 'response would release the round twice', + ); + }); + + test('a different consignment gets a different key', () async { + await MilerApi.startDelivery(34); + final first = sent.single.headers['Idempotency-Key']; + stub(); + await MilerApi.startDelivery(35); + expect(sent.single.headers['Idempotency-Key'], isNot(first)); + }); + + test( + 'the write that is already running is waited out, not reported', + () async { + var calls = 0; + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + calls++; + // The first attempt's response was lost; the server is running it. + if (calls == 1) { + return jsonRes({ + 'success': false, + 'code': 'IDEMPOTENCY_IN_PROGRESS', + 'message': 'in progress', + }, 409); + } + return jsonRes({ + 'success': true, + 'data': {'status': 'Out_for_Delivery'}, + }); + }); + + final res = await MilerApi.startDelivery(34); + expect( + res.ok, + isTrue, + reason: + 'telling a rider his round failed while it is in the act of ' + 'succeeding sends him to press the same refusal again', + ); + expect(calls, 2); + }, + ); + + test('a failure names the code, not just the prose', () async { + stub( + body: const { + 'success': false, + 'code': 'INVALID_STATE', + 'message': 'consignment is not collected', + }, + code: 400, + ); + final res = await MilerApi.startDelivery(34); + expect(res.ok, isFalse); + expect( + res.isInvalidState, + isTrue, + reason: 'branching on message text breaks the day it is reworded', + ); + }); + }); + + group('the list row carries the delivery half', () { + Map booking(String status, {String? consignment}) => { + 'bookingid': 59, + 'bookingreference': 'DM-BK-501CB551-45130', + 'status': status, + if (consignment != null) 'consignmentstatus': consignment, + 'consignmentid': 34, + }; + + test('the consignment id arrives on the row, so nothing has to hunt', () { + // The 17-of-29 blocker: bookings carried no consignment id and + // /miler/assignments listed only 12 of them, so for 17 collected stops + // the delivery reference was unobtainable by any route. + final stop = ApiConfig.pickupFromBooking( + booking('Converted_To_Consignment', consignment: 'Collected_By_Miler'), + ); + expect(stop['consignmentid'], '34'); + }); + + test('a delivered consignment is not an open delivery stop', () { + final stop = ApiConfig.pickupFromBooking( + booking('Converted_To_Consignment', consignment: 'Delivered'), + ); + expect( + stop['orderstatus'], + 'delivered', + reason: + 'the booking word is terminal at conversion and says the same ' + 'thing for a parcel in the box and one handed over an hour ago', + ); + }); + + test('collected and on-the-road are told apart on the list', () { + expect( + ApiConfig.pickupFromBooking( + booking( + 'Converted_To_Consignment', + consignment: 'Collected_By_Miler', + ), + )['orderstatus'], + 'picked', + ); + expect( + ApiConfig.pickupFromBooking( + booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'), + )['orderstatus'], + 'outfordelivery', + ); + }); + + test('a hub-side consignment keeps the booking’s own word', () { + // Not this rider's parcel to act on, and it has no rung on his card — + // inventing one would draw somebody else's warehouse on his screen. + final stop = ApiConfig.pickupFromBooking( + booking('Converted_To_Consignment', consignment: 'Inwarded_at_Hub'), + ); + expect(stop['orderstatus'], 'picked'); + }); + + test('a row with no consignment status behaves exactly as before', () { + expect( + ApiConfig.pickupFromBooking(booking('Pickup_Scheduled'))['orderstatus'], + 'accepted', + ); + expect( + ApiConfig.pickupFromBooking( + booking('Converted_To_Consignment'), + )['orderstatus'], + 'picked', + ); + }); + }); + + group('arrival is a rung the hub can see', () { + test('Arrived_At_Pickup is the status reached writes', () { + expect( + BookingStatus.parse('Arrived_At_Pickup'), + BookingStatus.arrivedAtPickup, + ); + expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived'); + }); + + test('the older At_Customer spelling means the same thing', () { + expect(BookingStatus.parse('At_Customer'), BookingStatus.arrivedAtPickup); + expect(ApiConfig.legacyStatusFromNew('At_Customer'), 'arrived'); + }); + + test('arriving is not collecting', () { + expect( + BookingStatus.arrivedAtPickup.isCollected, + isFalse, + reason: + 'the work boundary must not move a bag the rider has not ' + 'picked up yet onto the delivery tab', + ); + expect(BookingStatus.arrivedAtPickup.isOpen, isTrue); + expect(BookingStatus.arrivedAtPickup.canCancel, isTrue); + }); + }); +} diff --git a/test/completed_store_test.dart b/test/completed_store_test.dart index cc71c74..983de49 100644 --- a/test/completed_store_test.dart +++ b/test/completed_store_test.dart @@ -14,10 +14,10 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); Map booking(String id) => { - 'orderid': id, - 'pickupcustomer': 'Sri Balaji Stores', - 'pickupaddress': '19, Thadagam Road, Coimbatore', - }; + 'orderid': id, + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Coimbatore', + }; String today() { final t = DateTime.now(); @@ -64,6 +64,25 @@ void main() { expect(await getCompletedBookings(), hasLength(1)); }); + // ── What Home stamps over a lagging queue ── + // + // Home reads these ids and rewrites `orderstatus` to 'picked' on any stop the + // queue is still reporting as live. Without it the row the rider has just + // confirmed sits on Home wearing its LIVE mark until the backend catches up, + // which reads as the confirm not having worked. + test('the finished ids are readable on their own', () async { + await addCompletedBookings([booking('A')]); + await addCompletedBookings([booking('B')], cancelled: true); + + // Both count as closed: a stop written off is as finished, for Home's + // purposes, as one picked up. + expect(await getCompletedOrderIds(), {'A', 'B'}); + }); + + test('nothing finished is an empty set, not a null', () async { + expect(await getCompletedOrderIds(), isEmpty); + }); + // ── The timings ── // // Activity's stop detail can only answer "when did I get there" and "how long @@ -72,72 +91,90 @@ void main() { // moment they can be captured — and capturing a stale one is worse than // capturing none, because another stop's clock reads as fact. group('timings', () { - Map withPickup(String id) => - {...booking(id), 'pickupid': '77'}; + Map withPickup(String id) => { + ...booking(id), + 'pickupid': '77', + }; - test('the departure and arrival stamps are carried onto the record', - () async { - SharedPreferences.setMockInitialValues({ - kStopStartedKey('77'): '2026-08-06T09:12:00.000', - kStopArrivedKey('77'): '2026-08-06T09:31:00.000', - }); + test( + 'the departure and arrival stamps are carried onto the record', + () async { + SharedPreferences.setMockInitialValues({ + kStopStartedKey('77'): '2026-08-06T09:12:00.000', + kStopArrivedKey('77'): '2026-08-06T09:31:00.000', + }); - await addCompletedBookings([withPickup('A')]); + await addCompletedBookings([withPickup('A')]); - final done = await getCompletedBookings(); - expect(done.single['startedat'], '2026-08-06T09:12:00.000'); - expect(done.single['arrivedat'], '2026-08-06T09:31:00.000'); - }); + final done = await getCompletedBookings(); + expect(done.single['startedat'], '2026-08-06T09:12:00.000'); + expect(done.single['arrivedat'], '2026-08-06T09:31:00.000'); + }, + ); - test('the keys are cleared, so the next stop cannot inherit them', - () async { - SharedPreferences.setMockInitialValues({ - kStopStartedKey('77'): '2026-08-06T09:12:00.000', - kStopArrivedKey('77'): '2026-08-06T09:31:00.000', - }); + test( + 'the keys are cleared, so the next stop cannot inherit them', + () async { + SharedPreferences.setMockInitialValues({ + kStopStartedKey('77'): '2026-08-06T09:12:00.000', + kStopArrivedKey('77'): '2026-08-06T09:31:00.000', + }); - await addCompletedBookings([withPickup('A')]); + await addCompletedBookings([withPickup('A')]); - final prefs = await SharedPreferences.getInstance(); - expect(prefs.getString(kStopStartedKey('77')), isNull); - expect(prefs.getString(kStopArrivedKey('77')), isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(kStopStartedKey('77')), isNull); + expect(prefs.getString(kStopArrivedKey('77')), isNull); - // A second stop sharing the pickup id — a resumed skip worked twice in - // one day — must not report the first attempt's clock. - await addCompletedBookings([withPickup('B')]); - final second = (await getCompletedBookings()) - .firstWhere((b) => b['orderid'] == 'B'); - expect(second['startedat'], isNull); - }); + // A second stop sharing the pickup id — a resumed skip worked twice in + // one day — must not report the first attempt's clock. + await addCompletedBookings([withPickup('B')]); + final second = (await getCompletedBookings()).firstWhere( + (b) => b['orderid'] == 'B', + ); + expect(second['startedat'], isNull); + }, + ); - test('a stop closed without ever opening the map claims no timings', - () async { - // Bulk-completed from Home: nothing wrote a departure, so nothing is - // claimed, and the detail panel says "not recorded" instead of inventing - // a figure that cannot be caught. - await addCompletedBookings([withPickup('A')]); + test( + 'a stop closed without ever opening the map claims no timings', + () async { + // Bulk-completed from Home: nothing wrote a departure, so nothing is + // claimed, and the detail panel says "not recorded" instead of inventing + // a figure that cannot be caught. + await addCompletedBookings([withPickup('A')]); - final done = await getCompletedBookings(); - expect(done.single['startedat'], isNull); - expect(done.single['arrivedat'], isNull); - }); + final done = await getCompletedBookings(); + expect(done.single['startedat'], isNull); + expect(done.single['arrivedat'], isNull); + }, + ); }); // The store is per-day: yesterday's work is not today's activity, and a store // that never forgets grows for the life of the install. test('yesterday is dropped, today is kept', () async { - SharedPreferences.setMockInitialValues({ - 'completed_bookings': jsonEncode([ - {...booking('OLD'), 'completedday': '2020-01-01', 'orderstatus': 'picked'}, + SharedPreferences.setMockInitialValues({}); + await debugSeedStore( + 'completed_bookings', + jsonEncode([ + { + ...booking('OLD'), + 'completedday': '2020-01-01', + 'orderstatus': 'picked', + }, {...booking('NEW'), 'completedday': today(), 'orderstatus': 'picked'}, ]), - }); + ); final done = await getCompletedBookings(); expect(done.map((b) => b['orderid']).toList(), ['NEW']); // And the prune is written back, not recomputed on every read. final prefs = await SharedPreferences.getInstance(); - expect(prefs.getString('completed_bookings'), isNot(contains('OLD'))); + expect( + prefs.getString(await debugStoreKey('completed_bookings')), + isNot(contains('OLD')), + ); }); } diff --git a/test/consignment_state_test.dart b/test/consignment_state_test.dart new file mode 100644 index 0000000..f0f93f6 --- /dev/null +++ b/test/consignment_state_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/consignment_state.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// BOOKING WORDS ARE NOT CONSIGNMENT WORDS +/// +/// The delivery half of the lifecycle lives on the consignment, and the app +/// spent a long time inferring it from the booking — whose story ends at +/// `Converted_To_Consignment` and never mentions delivery at all. These pin +/// the vocabulary boundary and the one state `deliver` accepts. +/// +/// Root cause this suite exists to prevent, verified against the live API on +/// 2026-08-21: `POST /miler/consignments/:id/deliver` answers +/// `400 consignment is not out for delivery` for **two opposite** situations — +/// "still at the hub" and "already delivered". Consignment 34's own history +/// read `Out_for_Delivery` then `Delivered`; the app showed the rider an error +/// for work he had completed an hour earlier, because the only state it could +/// see was the booking's terminal word. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + group('parsing', () { + test('the consignment vocabulary is understood in every spelling', () { + expect( + consignmentStateFromRaw('Out_for_Delivery'), + ConsignmentState.outForDelivery, + ); + expect( + consignmentStateFromRaw('out for delivery'), + ConsignmentState.outForDelivery, + ); + expect( + consignmentStateFromRaw('OUTFORDELIVERY'), + ConsignmentState.outForDelivery, + ); + expect(consignmentStateFromRaw('Delivered'), ConsignmentState.delivered); + expect( + consignmentStateFromRaw('Inwarded_at_Hub'), + ConsignmentState.inwardedAtHub, + ); + }); + + test('BOOKING words are not consignment states', () { + // The exact confusion that produced the bug: these are booking-side + // words, and none of them may resolve to a deliverable consignment. + for (final booking in const [ + 'Converted_To_Consignment', + 'Picked_Up', + 'Pickup_Scheduled', + 'Miler_Assigned', + 'Pending_Pickup', + ]) { + final state = consignmentStateFromRaw(booking); + expect( + state, + ConsignmentState.unknown, + reason: + '"$booking" is a BOOKING status; it must not name a ' + 'consignment state', + ); + expect(state.isDeliverable, isFalse); + } + }); + + test('an unknown status is never deliverable and never finished', () { + final s = consignmentStateFromRaw('Teleported'); + expect(s, ConsignmentState.unknown); + expect(s.isDeliverable, isFalse); + expect(s.isDelivered, isFalse); + expect(s.isClosed, isFalse); + expect(s.awaitsHub, isFalse); + expect(ConsignmentGate.gateFor(s), DeliverGate.unknown); + }); + + test('empty and null are unknown, not deliverable', () { + expect(consignmentStateFromRaw(null).isDeliverable, isFalse); + expect(consignmentStateFromRaw('').isDeliverable, isFalse); + }); + }); + + group('the gate', () { + test('only Out_for_Delivery may be delivered', () { + for (final s in ConsignmentState.values) { + expect( + s.isDeliverable, + s == ConsignmentState.outForDelivery, + reason: + '${s.name} must ${s == ConsignmentState.outForDelivery ? '' : 'not '}' + 'be deliverable', + ); + } + expect( + ConsignmentGate.gateFor(ConsignmentState.outForDelivery), + DeliverGate.deliverable, + ); + }); + + test('an already-delivered consignment reconciles, it does not error', () { + expect( + ConsignmentGate.gateFor(ConsignmentState.delivered), + DeliverGate.alreadyDelivered, + reason: 'the work is done server-side; the device is what is behind', + ); + }); + + test('a genuine hub hold stays blocked — the logistics guard', () { + // The guard the brief requires be kept: a consignment inside the hub's + // half of the network is not this rider's to hand over. + for (final s in const [ + ConsignmentState.created, + ConsignmentState.inwardedAtHub, + ConsignmentState.tripsheetLoaded, + ConsignmentState.inTransit, + ]) { + expect(s.awaitsHub, isTrue, reason: '${s.name} is hub-side'); + expect(s.isDeliverable, isFalse); + expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingHub); + } + }); + + test('closed work is refused without blaming the rider', () { + for (final s in const [ + ConsignmentState.cancelled, + ConsignmentState.returnedToSender, + ]) { + expect(ConsignmentGate.gateFor(s), DeliverGate.closed); + expect(s.isDeliverable, isFalse); + } + }); + + test('an unreadable state does not block the rider', () async { + // A failed GET is not evidence of anything. Blocking here would strand a + // rider at a door because a read timed out; the server stays the judge. + expect( + ConsignmentGate.gateFor(ConsignmentState.unknown), + DeliverGate.unknown, + ); + // No id is unreadable, and must not throw or hang. + expect(await ConsignmentGate.stateOf(''), ConsignmentState.unknown); + expect(await ConsignmentGate.stateOf('0'), ConsignmentState.unknown); + }); + }); +} diff --git a/test/contrast_contract_test.dart b/test/contrast_contract_test.dart index 81df176..dfc0d85 100644 --- a/test/contrast_contract_test.dart +++ b/test/contrast_contract_test.dart @@ -63,9 +63,13 @@ void main() { surfaces.forEach((bgName, bg) { test('$name on $bgName', () { final r = ratio(colour, bg); - expect(r, greaterThanOrEqualTo(aaNormal), - reason: '$name on $bgName is ${r.toStringAsFixed(2)}:1, ' - 'under the $aaNormal:1 AA floor for normal text'); + expect( + r, + greaterThanOrEqualTo(aaNormal), + reason: + '$name on $bgName is ${r.toStringAsFixed(2)}:1, ' + 'under the $aaNormal:1 AA floor for normal text', + ); }); }); }); @@ -138,11 +142,15 @@ void main() { surfaces.forEach((bgName, bg) { test('text-safe: $name reads on $bgName', () { final r = ratio(colour, bg); - expect(r, greaterThanOrEqualTo(aaNormal), - reason: '$name on $bgName is ${r.toStringAsFixed(2)}:1. Either ' - 'lift the colour or move it out of the text-safe list — a ' - 'token that is in no list is how #FF9500 ended up carrying ' - 'the retry notice at 2.20:1'); + expect( + r, + greaterThanOrEqualTo(aaNormal), + reason: + '$name on $bgName is ${r.toStringAsFixed(2)}:1. Either ' + 'lift the colour or move it out of the text-safe list — a ' + 'token that is in no list is how #FF9500 ended up carrying ' + 'the retry notice at 2.20:1', + ); }); }); }); @@ -150,8 +158,11 @@ void main() { fillWithWhiteLabel.forEach((name, fill) { test('fill: $name carries a white label', () { final r = ratio(Colors.white, fill); - expect(r, greaterThanOrEqualTo(aaNormal), - reason: 'white on $name is ${r.toStringAsFixed(2)}:1'); + expect( + r, + greaterThanOrEqualTo(aaNormal), + reason: 'white on $name is ${r.toStringAsFixed(2)}:1', + ); }); }); @@ -163,17 +174,25 @@ void main() { // asserted is that the difference is real and cannot erode to nothing. // `borderStrong` is held higher, separately, because its job is to make // a container read as a container in sunlight. - expect(r, greaterThan(1.15), - reason: '$name is ${r.toStringAsFixed(3)}:1 on white — an edge ' - 'nobody can see'); + expect( + r, + greaterThan(1.15), + reason: + '$name is ${r.toStringAsFixed(3)}:1 on white — an edge ' + 'nobody can see', + ); }); test('structural: $name is never a text colour', () { // It cannot pass as text, which is exactly why it must not be used as // one. `disabledFill` was, on seven screens, at 1.68:1. final r = ratio(colour, Colors.white); - expect(r, lessThan(aaNormal), - reason: '$name reads well enough to be text — if that is what it ' - 'is for, move it to the text-safe list'); + expect( + r, + lessThan(aaNormal), + reason: + '$name reads well enough to be text — if that is what it ' + 'is for, move it to the text-safe list', + ); }); }); @@ -190,9 +209,13 @@ void main() { }; for (final e in retired.entries) { final r = ratio(Color(e.value), Colors.white); - expect(r, lessThan(aaNormal), - reason: 'sanity: ${e.key} really did fail at ' - '${r.toStringAsFixed(2)}:1'); + expect( + r, + lessThan(aaNormal), + reason: + 'sanity: ${e.key} really did fail at ' + '${r.toStringAsFixed(2)}:1', + ); } }); }); @@ -213,8 +236,11 @@ void main() { final r = ratio(Colors.white, fill); // Button and chip labels are bold, so the large-text floor applies — // but everything here should be comfortably past it. - expect(r, greaterThanOrEqualTo(aaLarge), - reason: 'white on $name is ${r.toStringAsFixed(2)}:1'); + expect( + r, + greaterThanOrEqualTo(aaLarge), + reason: 'white on $name is ${r.toStringAsFixed(2)}:1', + ); }); }); @@ -227,9 +253,13 @@ void main() { 'combinedChip': ColorConstants.combinedChip, }.entries) { final r = ratio(Colors.white, e.value); - expect(r, greaterThanOrEqualTo(aaNormal), - reason: '${e.key} is ${r.toStringAsFixed(2)}:1 — the chip label is ' - '10sp, so large-text allowances do not apply'); + expect( + r, + greaterThanOrEqualTo(aaNormal), + reason: + '${e.key} is ${r.toStringAsFixed(2)}:1 — the chip label is ' + '10sp, so large-text allowances do not apply', + ); } }); }); @@ -239,12 +269,17 @@ void main() { // Riders reported being unable to tell cards apart at ~3%; this keeps the // separation from quietly eroding back to that. test('cardSurface is clearly darker than the page', () { - final drop = 1 - + final drop = + 1 - luminance(ColorConstants.cardSurface) / luminance(ColorConstants.surface); - expect(drop, greaterThan(0.07), - reason: 'only ${(drop * 100).toStringAsFixed(1)}% darker than the ' - 'page — a borderless card needs its fill to do the separating'); + expect( + drop, + greaterThan(0.07), + reason: + 'only ${(drop * 100).toStringAsFixed(1)}% darker than the ' + 'page — a borderless card needs its fill to do the separating', + ); }); test('borderStrong is visible against white and against the card', () { @@ -254,9 +289,13 @@ void main() { 'cardSurface': ColorConstants.cardSurface, }.entries) { final r = ratio(ColorConstants.borderStrong, e.value); - expect(r, greaterThan(1.2), - reason: 'borderStrong on ${e.key} is only ' - '${r.toStringAsFixed(2)}:1 — an edge nobody can see'); + expect( + r, + greaterThan(1.2), + reason: + 'borderStrong on ${e.key} is only ' + '${r.toStringAsFixed(2)}:1 — an edge nobody can see', + ); } }); }); @@ -268,11 +307,15 @@ void main() { // dashboard was slate-and-white. These assert they stay neutral: for a // grey/slate, the three channels sit close together. void expectNeutral(String name, Color c) { - final spread = [c.r, c.g, c.b].reduce(math.max) - - [c.r, c.g, c.b].reduce(math.min); - expect(spread, lessThan(0.12), - reason: '$name has an RGB spread of ${spread.toStringAsFixed(3)} — ' - 'that is a hue, not a neutral'); + final spread = + [c.r, c.g, c.b].reduce(math.max) - [c.r, c.g, c.b].reduce(math.min); + expect( + spread, + lessThan(0.12), + reason: + '$name has an RGB spread of ${spread.toStringAsFixed(3)} — ' + 'that is a hue, not a neutral', + ); } test('onSurfaceVariant is a neutral, not a brown', () { diff --git a/test/deliver_identity_test.dart b/test/deliver_identity_test.dart new file mode 100644 index 0000000..759c54f --- /dev/null +++ b/test/deliver_identity_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/mutation_guard.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/Models/stop_status.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE DELIVERY WRITES AGAINST THE CONSIGNMENT, ONCE +/// +/// Three ids exist for one job and they come from three different sequences: +/// +/// `bookingid` the customer's booking +/// `bookingassignmentid` the row that gave it to this rider +/// `consignmentid` what the booking BECAME at pickup-complete +/// +/// `accept`/`reject` key on the assignment, `reached`/`pickup-complete` on the +/// booking, and `deliver`/`skip` on the **consignment**. Passing the wrong one +/// does not fail loudly — the backend answers 404, or worse finds a row that +/// happens to share the number — so these pin the mapping the delivery path +/// depends on. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + setUp(() { + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userid': 38}); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('pickup-complete preserves the real consignment id', () { + test('the id minted at the pivot survives to the door', () async { + // `pickup-complete` is the only moment the consignment id is guaranteed + // to be in hand: `GET /miler/bookings` does not return it at all + // (verified against the live API), so if the pivot does not record it, + // the delivery leg has nothing to write against. + await rememberConsignmentId('ORDER-1', '46'); + final ids = await getConsignmentIds(); + expect(ids['ORDER-1'], '46'); + }); + + test('a booking id is never mistaken for a consignment id', () async { + // Booking 75 became consignment 46 — different sequences, and the + // mapping is keyed by the ORDER, never by the number itself. + await rememberConsignmentId('ORDER-1', '46'); + final ids = await getConsignmentIds(); + expect( + ids['ORDER-1'], + isNot('75'), + reason: 'the booking id must never end up in the consignment slot', + ); + expect( + ids.containsKey('75'), + isFalse, + reason: 'the map is keyed by order, not by booking number', + ); + }); + + test('the mapping is per rider and per line', () async { + await rememberConsignmentId('ORDER-1', '46'); + + ServiceProfile.setActive(ServiceProfile.parcel); + expect( + (await getConsignmentIds())['ORDER-1'], + isNull, + reason: "one line's consignment mapping is not another's", + ); + + SharedPreferences.setMockInitialValues({'userid': 99}); + ServiceProfile.setActive(ServiceProfile.milkMan); + expect( + (await getConsignmentIds())['ORDER-1'], + isNull, + reason: "another rider's handset drawer is not this one", + ); + }); + + test('a delivered order forgets its mapping', () async { + await rememberConsignmentId('ORDER-1', '46'); + await forgetConsignmentIds(['ORDER-1']); + expect( + (await getConsignmentIds())['ORDER-1'], + isNull, + reason: 'nothing else may be posted against a closed consignment', + ); + }); + }); + + group('a missing consignment id is reported honestly', () { + // Verified against production 21 Aug 2026: booking 59 is + // `Converted_To_Consignment` and on the Deliveries tab, yet no route + // exposes its consignment id — `/miler/bookings` carries none on any of + // its 29 rows and `/miler/assignments` lists only 12 of those bookings. + // + // The app cannot deliver it. What it CAN do is tell the rider the truth: + // a collected order with an unreachable reference is a hub problem, not + // "you forgot to pick it up" — advice that sends him to look for a stop + // that is not on Home, to redo work he has already done. + test('a collected stop and an uncollected one are different states', () { + // `Converted_To_Consignment` maps to `picked` — collected. + expect(stopStatusFromRaw('picked').isPicked, isTrue); + expect(stopStatusFromRaw('outfordelivery').isDeliveryLeg, isTrue); + + // Still awaiting collection — the "pick it up from Home" advice is + // correct only for these. + expect(stopStatusFromRaw('assigned').isPicked, isFalse); + expect(stopStatusFromRaw('assigned').isDeliveryLeg, isFalse); + expect(stopStatusFromRaw('accepted').isPicked, isFalse); + expect(stopStatusFromRaw('accepted').isDeliveryLeg, isFalse); + }); + }); + + group('one press, one write', () { + test('a duplicate Delivered tap produces one mutation', () async { + var calls = 0; + Future deliver() async { + calls++; + await Future.delayed(const Duration(milliseconds: 30)); + return 'done'; + } + + // Both taps land while the first request is still in flight — which is + // what a gloved thumb on a moving bike actually produces. + final first = MutationGuard.run('delivered:ORDER-1', deliver); + final second = MutationGuard.run('delivered:ORDER-1', deliver); + final results = await Future.wait([first, second]); + + expect(calls, 1, reason: 'the second press must be dropped, not queued'); + expect(results.where((r) => r != null).length, 1); + expect( + results.contains(null), + isTrue, + reason: 'the dropped press returns null so the caller keeps the stop', + ); + }); + + test('the guard frees the key so a genuine retry still works', () async { + var calls = 0; + Future deliver() async { + calls++; + return 'done'; + } + + await MutationGuard.run('delivered:ORDER-1', deliver); + await MutationGuard.run('delivered:ORDER-1', deliver); + expect( + calls, + 2, + reason: 'a retry after the first completed is a real second attempt', + ); + }); + + test('two different stops do not block each other', () async { + var a = 0; + var b = 0; + await Future.wait([ + MutationGuard.run('delivered:ORDER-1', () async { + await Future.delayed(const Duration(milliseconds: 20)); + a++; + return 'a'; + }), + MutationGuard.run('delivered:ORDER-2', () async { + await Future.delayed(const Duration(milliseconds: 20)); + b++; + return 'b'; + }), + ]); + expect(a, 1); + expect(b, 1); + }); + + test('the outcome is part of the key', () async { + // Delivered and Skipped are different writes against the same stop; one + // must not silently swallow the other. + var delivered = 0; + var skipped = 0; + await Future.wait([ + MutationGuard.run('delivered:ORDER-1', () async { + await Future.delayed(const Duration(milliseconds: 20)); + delivered++; + return 'd'; + }), + MutationGuard.run('skipped:ORDER-1', () async { + await Future.delayed(const Duration(milliseconds: 20)); + skipped++; + return 's'; + }), + ]); + expect(delivered, 1); + expect(skipped, 1); + }); + }); + + group('completing a delivery clears the rider’s hands', () { + test('a delivered order stops being carried', () async { + await addCollectedOrderIds(['ORDER-1', 'ORDER-2']); + expect(await getCollectedOrderIds(), containsAll(['ORDER-1', 'ORDER-2'])); + + await removeCollectedOrderIds(['ORDER-1']); + final carried = await getCollectedOrderIds(); + expect(carried, isNot(contains('ORDER-1'))); + expect( + carried, + contains('ORDER-2'), + reason: 'closing one stop must not drop the rest of the load', + ); + }); + }); +} diff --git a/test/deliveries_queue_test.dart b/test/deliveries_queue_test.dart new file mode 100644 index 0000000..d4a6d58 --- /dev/null +++ b/test/deliveries_queue_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/stop_area.dart'; +import 'package:miler/views/Dashboard/home/homepage_banner.dart'; +import 'package:miler/views/Dashboard/pickups/pickups.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE WORK QUEUE, NOT A LIST OF CARDS +/// +/// Deliveries answers one question — *where do I go now, and what do I do +/// there* — and the whole screen is ranked around it: NOW is the command +/// object, UP NEXT is a glance, LATER recedes. That ranking has to be legible +/// **before** the labels are read, or the labels are the only thing carrying +/// it. +/// +/// These pin the invariants that ranking depends on, plus the two geometry +/// rules the bottom of this screen has broken twice. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + _realAddresses(); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map drop(String id, String who, {String? address}) => { + 'orderid': id, + 'type': 'delivery', + 'orderstatus': 'outfordelivery', + 'pickupcustomer': who, + 'sourcename': 'DailyGrubs RS Puram Kitchen', + 'kitchenname': 'DailyGrubs RS Puram Kitchen', + 'pickupaddress': '9 Mettupalayam Road, RS Puram, Coimbatore 641002', + 'dropaddress': address ?? '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'pickupcontactno': '9787698259', + }; + + group('the collapsed queue names a place, not a street', () { + test('an area, never a truncated address', () { + // The screen's own anti-pattern, named in the brief: twenty rows of + // `SEQTEST Gandhipur…` answer nothing a blank line would not have. + final area = areaOf(drop('1', 'Joe Mathew')); + expect(area, isNot(contains('641004'))); + expect(area, isNot(contains('12,'))); + expect(area.length, lessThan(24)); + expect(RegExp(r'[0-9]').hasMatch(area), isFalse); + }); + + test('a delivery names where the bag is GOING', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + // Once he is carrying it, the kitchen is supporting information. The + // destination is the question. + // `Peelamedu`, not `SNS Colony` — the locality, not the sub-locality. + // See the live-data group above for why the rule prefers a one-word name. + expect(areaOf(drop('1', 'Joe Mathew'), preferDrop: true), 'Peelamedu'); + expect( + areaOf(drop('1', 'Joe Mathew'), preferDrop: false), + 'Mettupalayam Road'.contains('9') ? isNotEmpty : 'RS Puram', + ); + }); + + test('a stop with nothing usable yields nothing, never a placeholder', () { + expect(areaOf(const {'orderid': '1'}), ''); + }); + + test('one implementation, shared with Home', () { + // Two copies of this rule got the same subtle case wrong in the same + // way: `12 SNS Colony` returned as an area because it is not *entirely* + // digits. One function is one place to find that. + expect( + areaOf({'dropaddress': '4/2 Cross Cut Road, Gandhipuram, 641012'}), + 'Gandhipuram', + ); + }); + }); + + group('the bottom slot is one slot', () { + /// `maxHeight` is expressed in `.h`, like every dimension in this app, so + /// these run inside a pumped `ScreenUtilInit` rather than as pure tests. + Future ready(WidgetTester tester) async { + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: SizedBox.shrink()), + ), + ); + } + + testWidgets('the reservation follows whichever surface is showing', ( + tester, + ) async { + await ready(tester); + const inset = 34.0; + + // Live stop: the banner is up, and the floor clears the banner. + expect( + bottomReservationFor(inset: inset, live: true, unreleased: true), + inset + ActivePickupBanner.maxHeight, + ); + // A live stop suppresses the start bar, so the banner wins even when + // there is unreleased work — the two never stack. + expect( + bottomReservationFor(inset: inset, live: true, unreleased: false), + inset + ActivePickupBanner.maxHeight, + ); + // Nothing running, load in hand: the start bar. + expect( + bottomReservationFor(inset: inset, live: false, unreleased: true), + inset + StartRoundBar.maxHeight, + ); + }); + + testWidgets('the device inset is always counted', (tester) async { + await ready(tester); + // The regression: the live branch was a flat `80.h` that forgot the + // inset, so on a gesture-navigation phone the last row of the run sat + // behind the banner *and* behind the navigation. + for (final live in const [true, false]) { + final withInset = bottomReservationFor( + inset: 34, + live: live, + unreleased: true, + ); + final without = bottomReservationFor( + inset: 0, + live: live, + unreleased: true, + ); + expect(withInset - without, 34); + } + }); + + testWidgets('an idle queue reserves almost nothing', (tester) async { + await ready(tester); + expect( + bottomReservationFor(inset: 0, live: false, unreleased: false), + lessThan(StartRoundBar.maxHeight), + ); + }); + + testWidgets('both surfaces declare a real height', (tester) async { + await ready(tester); + // Measured, not guessed. Each is the only thing that knows how much of + // the list it covers. + expect(ActivePickupBanner.maxHeight, greaterThan(48)); + expect(StartRoundBar.maxHeight, greaterThan(48)); + }); + }); + + group('the command surface', () { + Future pumpBar( + WidgetTester tester, { + double width = 390, + double textScale = 1.0, + int count = 5, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + tester.view.physicalSize = Size(width * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + backgroundColor: ColorConstants.daylightSurface, + body: Align( + alignment: Alignment.bottomCenter, + child: StartRoundBar( + count: count, + busy: false, + onPressed: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('it says what is in hand and what the press does', ( + tester, + ) async { + await pumpBar(tester); + // Setting off is reversible and operational, so it is a press — unlike + // Picked and Delivered, which are assertions about custody. + expect(find.textContaining('Start'), findsOneWidget); + }); + + for (final (width, scale) in const [ + (390.0, 1.0), + (390.0, 1.3), + (390.0, 2.0), + (375.0, 2.0), + (320.0, 1.0), + (320.0, 2.0), + ]) { + testWidgets('${width.toInt()}px @ ${scale}x lays out clean', ( + tester, + ) async { + await pumpBar(tester, width: width, textScale: scale, count: 12); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('it never wraps itself in a second SafeArea', (tester) async { + await pumpBar(tester); + // `BottomPage.bottomInset` is the one owner of the device inset on this + // screen. A `SafeArea` here would count it twice and float the bar a + // navigation bar's height off the bottom of the screen. + expect( + find.descendant( + of: find.byType(StartRoundBar), + matching: find.byType(SafeArea), + ), + findsNothing, + ); + }); + }); +} + +/// Real addresses from the live tenant, added after seeing them render on a +/// device. The rule that only skipped digits printed the landmark — +/// `Coimbatore Gandhipuram Mofussil Bus Stand` — on every row of the run. +void _realAddresses() { + group('the area rule against live data', () { + const cases = { + 'Coimbatore Gandhipuram Mofussil Bus Stand, Sathyamurthi Road, ' + 'Gandhipuram, Ward 54, Central Zone, Coimbatore, ' + 'Coimbatore North, Coimbatore, Tamil Nadu, 641001, India': + 'Gandhipuram', + '12, SNS Colony, Peelamedu, Coimbatore 641004': 'Peelamedu', + '4 Cross, RS Puram, Coimbatore 641002': 'RS Puram', + '19, Thadagam Road, Venkitapuram, Coimbatore': 'Venkitapuram', + }; + + cases.forEach((address, expected) { + test('"${address.split(',').first}…" → $expected', () { + expect(areaOf({'dropaddress': address}), expected); + }); + }); + + test('a landmark never wins over the locality behind it', () { + final area = areaOf(const { + 'dropaddress': + 'Coimbatore Gandhipuram Mofussil Bus Stand, ' + 'Sathyamurthi Road, Gandhipuram, Coimbatore', + }); + expect(area.split(' ').length, 1, reason: 'an area is a name'); + expect(area, isNot(contains('Bus Stand'))); + }); + }); +} diff --git a/test/deliveries_queue_visible_test.dart b/test/deliveries_queue_visible_test.dart new file mode 100644 index 0000000..4558f11 --- /dev/null +++ b/test/deliveries_queue_visible_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A COLLECTED MILK RUN MUST STILL HAVE CARDS +/// +/// The Deliveries queue filtered on the raw rung — `!status.isPicked` — while +/// the progress rail directly above it read the profile-aware +/// [StopStatusX.isWorkComplete]. +/// +/// On a parcel route the two agree by accident: collection IS the end of that +/// job. On a milk run they disagree completely — `picked` is the middle of the +/// morning, with fifteen lunches in the rider's hands and none delivered. So +/// collecting a crate emptied the card list while the rail kept drawing the +/// stop as a node: a rail with work on it and nothing underneath. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// The queue's filter, as the screen applies it. + bool worked(StopStatus s) => !s.isCancelled && !s.isWorkComplete; + + group('milk run', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + test('a collected crate is still work, so it still has a card', () { + expect(worked(StopStatus.picked), isTrue); + expect(worked(StopStatus.outForDelivery), isTrue); + expect(worked(StopStatus.deliveryArrived), isTrue); + }); + + test('and it leaves the queue at the door, not at the kitchen', () { + expect(worked(StopStatus.delivered), isFalse); + expect(worked(StopStatus.cancelled), isFalse); + }); + + test('the rail and the card list agree at every rung', () { + // The bug in one assertion: these two predicates drove the same screen + // and disagreed about `picked`. + for (final s in StopStatus.values) { + expect( + worked(s), + isNot(s.isWorkComplete), + reason: '$s renders a node on the rail but no card, or the reverse', + ); + } + }); + }); + + group('parcel route', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + test('collection is the end of the job, so the card goes', () { + expect(worked(StopStatus.accepted), isTrue); + expect(worked(StopStatus.arrived), isTrue); + expect(worked(StopStatus.picked), isFalse); + }); + }); +} diff --git a/test/deliveries_shots_test.dart b/test/deliveries_shots_test.dart new file mode 100644 index 0000000..134fc44 --- /dev/null +++ b/test/deliveries_shots_test.dart @@ -0,0 +1,220 @@ +@Tags(['shots']) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/golden_fonts.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_progress_rail.dart'; +// `card.dart` is a `part of` this library, so [PickupCard] comes through it. +import 'package:miler/views/Dashboard/pickups/pickups.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; + +/// Visual harness for the Deliveries tab's two public pieces. +/// +/// `_MyPickupsState` cannot be pumped — Get, SharedPreferences, Geolocator and +/// a 3s poll — and the queue's three quieter tiers (`_NextCard`, `_LaterRow`, +/// `_SkippedRow`) are private to `card.dart`. What is reachable is exactly the +/// pair the redesign is about: [TripProgressRail], which is the head of the +/// screen, and [PickupCard], which is the one live card under it. +/// +/// The rail is shot at four route lengths because the documented gap against it +/// is that "a 21-stop route overflows it" — a claim about layout, which is +/// answerable by looking. Run with `--update-goldens`. +void main() { + setUpAll(() async { + // Same reasoning as `home_shots_test`: a golden rendered without the app's + // own family is a picture of the fallback, and Poppins ships one file per + // weight so each has to be registered under the family by hand. + await loadGoldenFonts(); + }); + + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + Map drop( + String id, + String customer, { + String status = 'picked', + String bag = '', + }) => { + 'orderid': id, + 'tripid': 'T1', + 'type': 'delivery', + 'pickupcustomer': customer, + 'name': customer, + 'sourcename': 'DailyGrubs RS Puram Kitchen', + 'kitchenid': 'K1', + 'kitchenname': 'DailyGrubs RS Puram Kitchen', + 'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002', + 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'deliveryaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'orderstatus': status, + 'pickupcontactno': '9787698259', + 'deliverycontactno': '9787698259', + if (bag.isNotEmpty) 'baglabel': bag, + 'Quantity': 1, + 'pickuplat': 11.0168, + 'pickuplon': 76.9558, + 'droplat': 11.0208, + 'droplon': 76.9598, + }; + + Future frame( + WidgetTester tester, + String name, + Widget child, { + double height = 400, + double width = 390, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userid': 1}); + + tester.view.physicalSize = Size(width * 3, height * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + // Design size == surface, so `.w`/`.h` are both exactly 1 and the shot + // does not lie about spacing. See the note in `home_shots_test`. + designSize: Size(width, height), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: ColorConstants.daylightSurface, + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: child, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('shots/deliveries_$name.png'), + ); + } + + /// A route of [total] stops with [done] delivered and the rider standing at + /// the next one — the shape the rail is actually asked to draw mid-round. + List route(int total, {int done = 0}) => [ + for (var i = 0; i < total; i++) + if (i < done) + StopProgress.done + else if (i == done) + StopProgress.current + else + StopProgress.pending, + ]; + + group('the route rail', () { + testWidgets('a short round lays out across the full width', (tester) async { + await frame( + tester, + 'rail_short', + TripProgressRail( + progress: route(5, done: 2), + slotLabel: 'Trip 1 · 10:00 AM – 1:00 PM', + remaining: const Duration(minutes: 74), + ), + height: 150, + ); + }); + + testWidgets('the documented 21-stop round', (tester) async { + await frame( + tester, + 'rail_21', + TripProgressRail( + progress: route(21, done: 6), + slotLabel: 'Trip 1 · 10:00 AM – 1:00 PM', + remaining: const Duration(minutes: 110), + ), + height: 150, + ); + }); + + testWidgets('nothing delivered yet — the whole load still aboard', ( + tester, + ) async { + await frame( + tester, + 'rail_21_fresh', + TripProgressRail( + progress: route(21), + slotLabel: 'Trip 1 · 10:00 AM – 1:00 PM', + remaining: const Duration(minutes: 130), + ), + height: 150, + ); + }); + + testWidgets('a 320pt phone at 1.3x text scale', (tester) async { + await frame( + tester, + 'rail_21_small', + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.3)), + child: TripProgressRail( + progress: route(21, done: 6), + slotLabel: 'Trip 1 · 10:00 AM – 1:00 PM', + remaining: const Duration(minutes: 110), + ), + ), + height: 170, + width: 320, + ); + }); + }); + + group('the live card', () { + testWidgets('picked, waiting to be taken out', (tester) async { + await frame( + tester, + 'card_picked', + PickupCard( + item: drop('D1', 'Joe Mathew', bag: 'Bag 8'), + displayStep: 7, + distanceMeters: 1100, + bag: 'Bag 8', + collectedIds: const {'D1'}, + ), + height: 320, + ); + }); + + testWidgets('active — the rider has set off', (tester) async { + await frame( + tester, + 'card_active', + PickupCard( + item: drop('D1', 'Joe Mathew', bag: 'Bag 8'), + displayStep: 7, + distanceMeters: 1100, + bag: 'Bag 8', + collectedIds: const {'D1'}, + outForDeliveryIds: const {'D1'}, + ), + height: 320, + ); + }); + }); +} diff --git a/test/delivery_details_test.dart b/test/delivery_details_test.dart new file mode 100644 index 0000000..4bcad7c --- /dev/null +++ b/test/delivery_details_test.dart @@ -0,0 +1,615 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import 'package:miler/data/order_events.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// DELIVERY DETAILS — one stop, the whole story +/// +/// The record used to open inside the Activity list. It is a page now, for two +/// reasons: an open record is several screens tall, so the list stopped being +/// scannable the moment it was used — and the two jobs want opposite things. +/// The list answers "did that go through?" in one word per row; this answers +/// "what happened on that one?", which is a timeline, a route, four +/// measurements and a reference. +/// +/// The page takes the record it is given and renders it — no fetch, no store +/// write — so these pump it directly. +/// +/// What they hold is the part that regresses silently: **a rung, a row or a +/// whole section is drawn only when the data behind it exists.** A record with +/// a hole in it can be investigated; one carrying a fabricated `0 km` cannot be +/// caught. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + final now = DateTime(2026, 8, 20, 11, 42); + + /// A finished stop. Every stamp is optional, exactly as it is in the store. + Map record({ + String type = 'delivery', + String status = 'delivered', + bool started = true, + bool arrived = true, + Map? compliance, + double cash = 0, + double? collected, + String? skipReason, + }) => { + 'orderid': 'DM101', + 'type': type, + 'pickupcustomer': 'Joe Mathew', + 'sourcename': 'Vidhya Kitchen', + 'pickupaddress': '78, Cross Cut Road, Gandhipuram', + 'orderstatus': status, + 'completedat': now.toIso8601String(), + if (started) + 'startedat': now.subtract(const Duration(minutes: 87)).toIso8601String(), + if (arrived) + 'arrivedat': now.subtract(const Duration(minutes: 5)).toIso8601String(), + if (compliance != null) 'compliance': compliance, + if (cash > 0) 'collectionamt': '$cash', + if (skipReason != null) 'skipreason': skipReason, + 'proof': { + 'collected': 2, + 'weight': '2.4', + if (collected != null) 'amountcollected': collected, + }, + }; + + Future pump(WidgetTester tester, Map stop) async { + // The record is a tall page and the `ListView` under it is lazy, so a + // default 800×600 harness never builds the sections below the timeline — + // and every assertion about them would pass or fail on viewport height + // rather than on the widget. Give it a phone's width and room to render. + tester.view.physicalSize = const Size(390 * 3, 2600 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp(home: DeliveryDetailsPage(stop: stop)), + ), + ); + await tester.pumpAndSettle(); + } + + /// Opens every folded group. + /// + /// ── Why the tests now tap first ── + /// + /// The ledger — distance, ETA, payment, the booking reference — used to be + /// drawn flat, at the same weight as the outcome, which is what made a + /// finished delivery read as a form to work through. It is folded now: three + /// rows the rider taps only when somebody in an office asks him something. + /// + /// The facts are unchanged and every assertion about them still holds. What + /// changed is that reaching them costs a tap, so the tests take it — the + /// alternative is asserting the *absence* of progressive disclosure, which + /// is the thing being introduced. + Future unfold(WidgetTester tester) async { + for (final title in const [ + 'Delivery details', + 'Payment', + 'Order details', + ]) { + // Matched inside its own control, not by text alone: the app bar's + // title is *also* "Delivery details", and a finder that catches both + // makes `tap` throw rather than fail an expectation — which is how one + // ambiguous line reads as nine unrelated broken tests. + final row = find.descendant( + of: find.byType(InkWell), + matching: find.text(title), + ); + if (row.evaluate().isEmpty) continue; + await tester.tap(row.first); + await tester.pumpAndSettle(); + } + } + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the summary', () { + testWidgets('leads with the outcome, in the outcome\'s colour', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record()); + + // The word comes from `StopStatusX.label` — the canonical list — so a + // stop cannot be called one thing on the list and another on the page + // that list opens. It appears twice: the summary and the timeline's head + // rung, which is the newest event. + expect(find.text('Delivered'), findsNWidgets(2)); + final headline = tester.widget(find.text('Delivered').first); + expect(headline.style!.color, ColorConstants.acceptGreen); + + // Bigger than the route under it, which is bigger than the note under + // that: the hierarchy is the answer, then which stop, then the detail. + // The route is two `Text`s with a glyph between them — Poppins carries no + // U+2192 and the arrow-as-character rendered as a tofu box — so `.first` + // is the summary's copy and `.last` the one in ROUTE below it. + final route = tester.widget(find.text('Vidhya Kitchen').first); + expect(headline.style!.fontSize, greaterThan(route.style!.fontSize!)); + expect(find.byIcon(LucideIcons.arrowRight), findsOneWidget); + }); + + testWidgets('a cancelled stop never heads itself "Delivered"', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record(status: 'cancelled')); + + expect(find.text('Cancelled'), findsWidgets); + // The *summary* is the outcome and it is Cancelled. `Delivered` appears + // once and only once, as the open-ring rung for the step this record + // never reached — see the group below. + final summary = tester.widget(find.text('Cancelled').first); + expect(summary.style!.fontSize, greaterThan(20)); + expect(find.text('Delivered'), findsOneWidget); + expect(find.text('Did not happen'), findsOneWidget); + }); + + testWidgets('a skipped stop leads with the reason it was put down', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump( + tester, + record(status: 'skipped', skipReason: 'Customer unreachable'), + ); + + expect(find.text('Skipped'), findsWidgets); + // The one thing on the record he cannot reconstruct for himself: he knows + // who and where, and what he has forgotten by the afternoon is *why he + // walked away*. + expect(find.textContaining('Customer unreachable'), findsWidgets); + }); + }); + + group('the timeline', () { + testWidgets('runs oldest first, named for the leg', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record()); + + // The door arrival is not a rung: it folds into the outcome as a + // duration, because "he got there 5m before he handed it over" is a + // subtraction, not an event worth its own line. + expect(find.text('Arrived at customer'), findsNothing); + expect(find.text('5m at the door'), findsOneWidget); + expect(find.text('Out for delivery'), findsOneWidget); + + // ── Downwards, like every order-tracking screen ── + // + // Placed at the top, the outcome at the bottom, so the eye finishes on + // what the record is about. The order is the whole reason this is a rail + // and not a list — the rider takes the sequence off the geometry, not off + // the clocks. `.last` is the timeline's rung; `.first` is the summary. + final off = tester.getRect(find.text('Out for delivery')); + final head = tester.getRect(find.text('Delivered').last); + expect(off.top, lessThan(head.top)); + + // One rail, so every title starts at the same gutter. + expect(head.left, closeTo(off.left, 0.5)); + }); + + testWidgets('a collection is named for its own leg', (tester) async { + await pump(tester, record(type: 'pickup', status: 'picked')); + + expect(find.text('Picked up'), findsWidgets); + // On a collection `arrivedat` IS the counter, so it fills the same rung + // the kitchen event fills on a round. + expect(find.text('Arrived'), findsOneWidget); + expect(find.text('Set off'), findsOneWidget); + + // Words from the other leg must not leak across. + expect(find.text('Out for delivery'), findsNothing); + }); + + testWidgets('an unstamped rung is absent, not a placeholder', ( + tester, + ) async { + // A stop closed in bulk from Home never opened the map, so nothing + // recorded a departure or an arrival. One event happened; one rung draws. + await pump( + tester, + record( + type: 'pickup', + status: 'picked', + started: false, + arrived: false, + ), + ); + + expect(find.text('Set off'), findsNothing); + expect(find.text('Arrived'), findsNothing); + + // None of the shapes a placeholder would take. A record that invents a + // plausible step is worse than one with a hole in it, because the hole + // can be investigated and the invention cannot be caught. + for (final invented in const [ + 'Pending', + 'Not recorded', + '--:--', + '12:00 AM', + ]) { + expect( + find.text(invented), + findsNothing, + reason: '"$invented" is a lie', + ); + } + }); + + testWidgets('the middle rung drops on its own', (tester) async { + await pump( + tester, + record(type: 'pickup', status: 'picked', arrived: false), + ); + expect(find.text('Set off'), findsOneWidget); + expect(find.text('Arrived'), findsNothing); + }); + }); + + group('the full history', () { + testWidgets('every moment the app recorded gets a rung', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + final base = DateTime(2026, 8, 20, 9, 10); + await pump(tester, { + ...record(), + // What the booking carries: when the order was created. + 'starttime': base + .subtract(const Duration(minutes: 25)) + .toIso8601String(), + // What the rider's own screens stamped as they happened. + OrderEvent.assigned: base + .subtract(const Duration(minutes: 9)) + .toIso8601String(), + OrderEvent.accepted: base.toIso8601String(), + OrderEvent.arrivedAtPickup: base + .add(const Duration(minutes: 18)) + .toIso8601String(), + OrderEvent.pickedUp: base + .add(const Duration(minutes: 25)) + .toIso8601String(), + OrderEvent.outForDelivery: base + .add(const Duration(minutes: 65)) + .toIso8601String(), + }); + + for (final rung in const [ + 'Assigned', + 'Accepted', + 'Arrived', + 'Picked up', + 'Out for delivery', + 'Delivered', + ]) { + expect(find.text(rung), findsWidgets, reason: '$rung is missing'); + } + + // ── A glyph per event, not one tick eight times ── + // + // A rail of identical marks says only "these all happened", which the + // rail says by being solid. A rider scanning for the moment he took the + // bag is looking for a shape. + for (final glyph in const [ + LucideIcons.mapPinned, + LucideIcons.squareCheckBig, + LucideIcons.store, + LucideIcons.package, + LucideIcons.bike, + ]) { + expect(find.byIcon(glyph), findsWidgets, reason: '$glyph is missing'); + } + + // Oldest at the top, whatever order the stamps were assembled in. + final tops = [ + for (final rung in const [ + 'Assigned', + 'Accepted', + 'Arrived', + 'Picked up', + 'Out for delivery', + 'Delivered', + ]) + tester.getRect(find.text(rung).last).top, + ]; + for (var i = 1; i < tops.length; i++) { + expect( + tops[i], + greaterThan(tops[i - 1]), + reason: 'rung $i is out of order', + ); + } + }); + + testWidgets('a record from before the ledger keeps its three rungs', ( + tester, + ) async { + // Everything already on a rider's device was written before these stamps + // existed. It must degrade to what it has, not to a page of placeholders. + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record()); + + expect(find.text('Delivered'), findsNWidgets(2)); + // The door arrival is not a rung: it folds into the outcome as a + // duration, because "he got there 5m before he handed it over" is a + // subtraction, not an event worth its own line. + expect(find.text('Arrived at customer'), findsNothing); + expect(find.text('5m at the door'), findsOneWidget); + expect(find.text('Out for delivery'), findsOneWidget); + for (final absent in const [ + 'Order placed', + 'Order accepted', + 'Arrived at pickup', + 'Picked up', + ]) { + expect(find.text(absent), findsNothing, reason: '$absent was invented'); + } + }); + }); + + group('the step that never happened', () { + testWidgets('a skipped stop shows the delivery it did not make', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump( + tester, + record(status: 'skipped', skipReason: 'Customer unreachable'), + ); + + // The one rung on this page allowed to exist without a timestamp: a + // skipped stop has a delivery in its story that did not occur, and saying + // so is the point of the record. It is an open ring, not a filled disc, + // so it cannot read as a step that is merely pending. + expect(find.text('Delivered'), findsOneWidget); + expect(find.text('Not completed — return visit owed'), findsOneWidget); + }); + + testWidgets('a completed stop has nothing left unreached', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record()); + + // Twice — the summary and the timeline's last rung — and never a third + // time as a step still to come. + expect(find.text('Delivered'), findsNWidgets(2)); + expect(find.text('Not completed — return visit owed'), findsNothing); + expect(find.text('Did not happen'), findsNothing); + }); + }); + + group('route', () { + testWidgets('a round draws both ends; a parcel draws one', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record()); + // ── ROUTE is gone; its two addresses moved onto the journey ── + // + // Tracking said *when*, Route said *where*, and the rider joined them + // himself. Each place is now written onto the rung that happened there. + expect(find.text('ROUTE'), findsNothing); + expect(find.text('JOURNEY'), findsOneWidget); + + // Once now, not twice. The hero's quiet `from → to` line names both + // ends; the journey names the place on the rung that happened there, + // with its address under it. A second ROUTE section printing the same + // two names again was the duplication this merge removed. + expect(find.text('Vidhya Kitchen'), findsOneWidget); + expect( + find.text('78, Cross Cut Road, Gandhipuram'), + findsOneWidget, + reason: + 'the address hangs on the rung that happened at it. With no ' + 'drop address on the booking the one address it has belongs to ' + 'the door — the fallback ROUTE encoded and this inherited.', + ); + + // A parcel booking is a first-mile collection *from* the customer, and + // where it goes next is the hub's problem — there is no second name to + // print, so there is no route to draw. + ServiceProfile.setActive(ServiceProfile.parcel); + await pump(tester, record(type: 'pickup', status: 'picked')); + expect(find.text('ROUTE'), findsNothing); + expect(find.text('JOURNEY'), findsOneWidget); + }); + }); + + group('the measurements', () { + testWidgets('only what was measured is shown', (tester) async { + await pump( + tester, + record( + type: 'pickup', + status: 'picked', + compliance: const {'ontime': true, 'actualkm': 4.6, 'plannedkm': 4.1}, + ), + ); + await unfold(tester); + + // ── The labelled stats strip is gone ── + // + // DISTANCE / TOTAL TIME / ORDERS was three captioned cells in a tinted + // box — a dashboard widget, and a dashboard is for comparing many things + // across records. There is one stop on this page and it is finished. The + // figures still earn a place; they earn a line. + expect(find.text('DISTANCE'), findsNothing); + expect(find.text('TOTAL TIME'), findsNothing); + + // The distance now reads once in that line, and once in the folded row + // that names it. + expect(find.text('4.6 km'), findsOneWidget); + expect(find.textContaining('4.6 km'), findsWidgets); + expect(find.text('4.1 km'), findsOneWidget); + + final verdict = tester.widget(find.text('Yes')); + expect(verdict.style!.color, ColorConstants.acceptGreen); + + // A record that invents a plausible number cannot be caught. + for (final invented in const ['0 km', '0.0 km', '0m', '₹0']) { + expect( + find.text(invented), + findsNothing, + reason: '"$invented" is a lie', + ); + } + }); + + testWidgets('an unmeasured stop says so out loud', (tester) async { + await pump(tester, record(type: 'pickup', status: 'picked')); + await unfold(tester); + + // Said out loud, not implied by a missing row: a dropped row would be + // indistinguishable from one that simply had not been scrolled to. + expect(find.text('Not measured'), findsOneWidget); + expect(find.text('Distance'), findsNothing); + }); + }); + + group('order and payment', () { + testWidgets('the reference is there and copyable', (tester) async { + await pump(tester, record(type: 'pickup', status: 'picked')); + await unfold(tester); + expect(find.text('#DM101'), findsOneWidget); + expect(find.byIcon(LucideIcons.copy), findsOneWidget); + }); + + testWidgets('a round shows no money and no shipment desk', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record(cash: 340)); + await unfold(tester); + + // Subscribers pay the client monthly; `collectsCash` is false, so not + // one collection row is drawn rather than printing "nothing to collect". + // + // The **group** still exists, because the stop payout lives in it — + // earnings folded into Payment rather than standing as a second group + // about money. What must be absent is any figure. + expect(find.text('Due at this stop'), findsNothing); + expect(find.text('Collected'), findsNothing); + expect(find.textContaining('₹'), findsNothing); + + // ── No fabricated payout ── + // + // `deliver` computes `ridercharges` server-side and returns nothing, and + // `/miler/earnings` answers for a period rather than a stop. A number + // here would be pay derived from a rate the app does not hold — the one + // invention on this screen a rider would act on and be wrong about. + expect(find.text('Stop payout'), findsOneWidget); + expect(find.text('See Account'), findsOneWidget); + + // Weight and parcel counts belong to the shipment desk, which is a + // capability this line does not run. + expect(find.text('Weight'), findsNothing); + expect(find.text('Parcels handed over'), findsNothing); + }); + + testWidgets('a parcel stop that collected shows what it took', ( + tester, + ) async { + await pump( + tester, + record(type: 'pickup', status: 'picked', cash: 340, collected: 340), + ); + await unfold(tester); + + expect(find.text('Payment'), findsWidgets); + // Due, collected, and again under EARNINGS — the cash that changed hands + // is the one earnings figure this stop really has. + expect(find.text('₹340'), findsNWidgets(3)); + // Earnings folded into Payment — one group about money, not two. + expect(find.text('Payment'), findsWidgets); + expect(find.text('Cash collected'), findsOneWidget); + expect(find.text('Weight'), findsOneWidget); + expect(find.text('2.4 kg'), findsOneWidget); + }); + + testWidgets('a prepaid parcel stop draws no payment section', ( + tester, + ) async { + await pump(tester, record(type: 'pickup', status: 'picked')); + await unfold(tester); + // Nothing was due and nothing was taken, so not one collection row is + // drawn — a section spent saying a section does not apply is a section + // too many. The group itself survives on the payout row alone. + expect(find.text('Due at this stop'), findsNothing); + expect(find.text('Collected'), findsNothing); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // ROUTE — each end owns its own address + // ══════════════════════════════════════════════════════════════════════ + // + // `_route` read `pickupaddress` **only** and hung it under the drop. On a + // parcel booking that is right by construction — one address, and the + // collection point IS the customer. On a milk run the payload carries both, + // so the record printed the kitchen's street under the customer's name: the + // most checkable fact on the one screen that exists to be checked. + group('route', () { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + testWidgets('a round hangs each address on its own end', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + // Both ends need a rung to hang on: with no collection stamp the + // journey has nothing that happened at the counter, and the kitchen's + // address would have nowhere to go. That is the correct behaviour — a + // place is only drawn on a moment the record actually holds — so the + // fixture supplies the moment. + // The fixture's own clock, not the wall clock: a rung stamped after the + // record closed is dropped on purpose (a timeline that contradicts + // itself is worse than a short one), and `record()` completes at a fixed + // past moment. + final at = now.subtract(const Duration(minutes: 40)); + await pump(tester, { + ...record(), + 'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002', + 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'arrived_at_pickup': at.toIso8601String(), + 'picked_up': at.add(const Duration(minutes: 4)).toIso8601String(), + }); + + // Both addresses now sit on the journey, each on the rung that happened + // at it — the counter on the collection, the door on the outcome — so + // this reads the ladder rather than a separate ROUTE section. + final kitchen = tester.getRect( + find.text('4 Cross, RS Puram, Coimbatore 641002'), + ); + final door = tester.getRect( + find.text('12, SNS Colony, Peelamedu, Coimbatore 641004'), + ); + + expect( + kitchen.top, + lessThan(door.top), + reason: 'the kitchen is the first end of the journey', + ); + expect( + tester.getRect(find.text('Joe Mathew').last).top, + lessThan(door.top), + reason: "the customer's door hangs under the customer, not the kitchen", + ); + }); + + testWidgets('one address still belongs to the door', (tester) async { + // The fallback, unchanged. A payload with no separate drop must keep + // hanging its one address exactly where it always hung. + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record()); + await unfold(tester); + + expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget); + }); + + testWidgets('the same street is never printed twice', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump(tester, record()); + await unfold(tester); + expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget); + }); + }); +} diff --git a/test/delivery_leg_test.dart b/test/delivery_leg_test.dart new file mode 100644 index 0000000..3afc1c7 --- /dev/null +++ b/test/delivery_leg_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/pickups.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE DELIVERY LEG HAS NO ARRIVAL STEP +/// +/// What the rider reported: pressing **Start delivery** opened the map, and the +/// map asked him to press **I've arrived** — which opened a sheet offering +/// *Picked up · Skipped · Not picked* for a bag already in his box. +/// +/// Both halves were wrong. Arrival at a customer's door is not a state anything +/// records — no endpoint exists for it — so the button wrote nothing and existed +/// only to reveal the next screen; and the screen it revealed was the pickup +/// flow's. A stop he is carrying is simply **active**, and the only question +/// left is how it ended. +/// +/// These pin the vocabulary and the routing of the three outcomes. The control +/// itself is a widget; what must not drift is which endpoint each outcome means +/// and which of them ends the order. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the three outcomes', () { + test('are exactly delivered, skip and cancelled', () { + expect(DeliveryOutcome.values.map((o) => o.label), [ + 'Delivered', + 'Skip', + 'Cancelled', + ]); + }); + + test('delivered is the only one that completes the order', () { + // Skip is a return visit — it increments `attemptcount` rather than + // failing the parcel — and cancelled is a withdrawal. Neither is a + // delivery, and Activity must not file them as one. + expect(DeliveryOutcome.delivered.pastTense, 'Delivered'); + expect(DeliveryOutcome.skipped.pastTense, 'Skipped'); + expect(DeliveryOutcome.cancelled.pastTense, 'Cancelled'); + }); + + test('each one is coloured by what it means, not by where it sits', () { + expect( + DeliveryOutcome.delivered.colour, + isNot(DeliveryOutcome.cancelled.colour), + ); + expect( + DeliveryOutcome.skipped.colour, + isNot(DeliveryOutcome.cancelled.colour), + ); + }); + }); + + group('which leg a stop is on decides which control it gets', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + Map stop(String status) => { + 'orderid': 'A1', + // The adapter stamps this on every row whatever leg it is on, which is + // why the leg is asked of [MilkRun.workingKind] and never of the payload. + 'type': 'pickup', + 'orderstatus': status, + 'kitchenname': 'Vidhya Kitchen', + }; + + test('a collected order is on the delivery leg', () { + expect(MilkRun.workingKind(stop('picked')).isDelivery, isTrue); + expect( + MilkRun.workingKind(stop('accepted'), collectedIds: {'A1'}).isDelivery, + isTrue, + ); + }); + + test('an uncollected one is not — it keeps the pickup flow', () { + // The arrival step and the confirmation sheet still belong to the pickup + // leg on logistics, where the collection *is* the job at the door. + expect(MilkRun.workingKind(stop('accepted')).isDelivery, isFalse); + expect(MilkRun.workingKind(stop('arrived')).isDelivery, isFalse); + }); + + test('and on logistics nothing is ever the delivery leg', () { + // That line's parcel goes to a hub and is delivered by somebody else, so + // the rider never drives to its customer and must keep I'VE ARRIVED. + ServiceProfile.setActive(ServiceProfile.parcel); + for (final status in ['accepted', 'arrived', 'picked']) { + expect( + MilkRun.workingKind(stop(status)).isDelivery, + isFalse, + reason: status, + ); + } + }); + }); +} diff --git a/test/delivery_line_test.dart b/test/delivery_line_test.dart new file mode 100644 index 0000000..e635b74 --- /dev/null +++ b/test/delivery_line_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/helpers/rider_shell.dart'; +import 'package:miler/widget/Bottom_page.dart' as parcel; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE XPRESS SUBTREE IS A REFERENCE, NOT A DESTINATION +/// +/// `lib/xpress` is a verbatim port of the Xpress-rider app, kept beside this +/// one so its proven delivery UX can be read while the Miler-native milk run is +/// built. It is **not** one of the two operations the hub runs, and the Milk Man +/// flow is implemented natively in Miler — Miler screens, Miler controllers, +/// Miler API layer — rather than by routing riders into it. +/// +/// ── What this file used to assert, and why it changed ── +/// +/// It used to prove the opposite: that a tenant *named* "delivery"/"xpress", or +/// an account in `configid: 6`, would land the rider in that other application. +/// That was reasonable while the tenant was unknowable — the build flag and a +/// name were the only signals there were. +/// +/// It is not reasonable now. `verify-pin` returns a real `tenantname`, which +/// means an arbitrary string chosen by whoever set up a client in the admin +/// console reaches this resolver. A client called "Delivery" is an entirely +/// plausible thing to create, and under the old rule that alone would have +/// dropped a live rider into a different app with a different bottom bar, a +/// different set of endpoints and no shipment desk. The failure would look, to +/// him, like the app breaking. +/// +/// So the wire cannot reach it any more. Only `--dart-define=TENANT=delivery` +/// can, and that is a compile-time constant no test can set — which is the +/// point. What is tested here is the closed door. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future resolveWith(Map prefs) async { + SharedPreferences.setMockInitialValues(prefs); + final profile = await resolveServiceProfile(); + ServiceProfile.setActive(profile); + return profile; + } + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the wire cannot reach the ported line', () { + test('no tenant name routes a rider out of Miler', () async { + // Every name that used to open it. Each one must now resolve to one of + // the two real operations — in practice logistics, the safe fallback. + for (final name in [ + 'delivery', + 'xpress', + 'express', + 'doormile xpress', + 'doormilexpress', + ' XPress ', + ]) { + final p = await resolveWith({'tenantname': name}); + expect( + p.isDelivery, + isFalse, + reason: 'tenantname=$name must not open the ported app', + ); + expect(p.line, ServiceLine.parcel, reason: 'tenantname=$name'); + } + }); + + test('no tenant id routes a rider out of Miler', () async { + expect(TenantController.deliveryTenantIds, isEmpty); + for (final id in [1, 6, 99, 1001]) { + final p = await resolveWith({'tenantid': id}); + expect(p.isDelivery, isFalse, reason: 'tenantid=$id'); + } + }); + + test('configid 6 no longer decides anything', () async { + // Xpress-rider signed its riders into partition 6, and this resolver used + // to read that as "give him the other app". A partition is an account + // location, not a statement about which business a rider works for, and + // it was the weakest of the three signals — so it is no longer consulted. + final p = await resolveWith({'configid': 6}); + expect(p.line, ServiceLine.parcel); + }); + + testWidgets('and no resolvable tenant produces the Xpress shell', ( + tester, + ) async { + for (final prefs in >[ + {'tenantname': 'xpress'}, + {'tenantname': 'delivery'}, + {'configid': 6}, + {'tenantid': 6}, + {}, + ]) { + await resolveWith(prefs); + expect(riderShell(), isA(), reason: '$prefs'); + } + }); + }); + + group('the two operations the hub actually runs', () { + test('a milk-man tenant resolves to the milk run', () async { + final p = await resolveWith({'tenantname': 'milkman'}); + expect(p.line, ServiceLine.milkMan); + expect(p.isMilkMan, isTrue); + }); + + test('a logistics tenant resolves to logistics', () async { + final p = await resolveWith({'tenantname': 'logistics'}); + expect(p.line, ServiceLine.parcel); + expect(p.isLogistics, isTrue); + }); + + test('an unknown tenant falls back to logistics', () async { + final p = await resolveWith({'tenantname': 'someclient', 'tenantid': 99}); + expect(p.line, ServiceLine.parcel); + }); + }); + + group('both operations share one Miler dashboard', () { + testWidgets('logistics gets it', (tester) async { + await resolveWith({'tenantname': 'logistics'}); + expect(riderShell(), isA()); + }); + + testWidgets('the milk run gets it too', (tester) async { + // Milk Man is a capability variant of the Miler screens, not a separate + // app — which is the whole point of §2 of the brief. + await resolveWith({'tenantname': 'milkman'}); + expect(riderShell(), isA()); + }); + + testWidgets('the requested tab is carried through', (tester) async { + await resolveWith({'tenantname': 'milkman'}); + expect( + (riderShell(initialIndex: 2) as parcel.BottomPage).initialIndex, + 2, + ); + + await resolveWith({'tenantname': 'logistics'}); + expect( + (riderShell(initialIndex: 2) as parcel.BottomPage).initialIndex, + 2, + ); + }); + + testWidgets('an override page is honoured', (tester) async { + const marker = SizedBox.shrink(); + + await resolveWith({'tenantname': 'milkman'}); + expect( + (riderShell(overridePage: marker) as parcel.BottomPage).overridePage, + same(marker), + ); + + await resolveWith({'tenantname': 'logistics'}); + expect( + (riderShell(overridePage: marker) as parcel.BottomPage).overridePage, + same(marker), + ); + }); + }); + + group('the ported profile still describes itself honestly', () { + // The profile object survives even though nothing off the wire selects it: + // the build flag still reaches it, and a profile that lies about the work + // would be worse than one nobody uses. + test('names the work Doormile, never the app it was ported from', () { + const p = ServiceProfile.delivery; + expect(p.label, 'Doormile'); + expect(p.label.toLowerCase(), isNot(contains('nearle'))); + expect(p.label.toLowerCase(), isNot(contains('xpress'))); + }); + + test('speaks in deliveries, not bookings', () { + const p = ServiceProfile.delivery; + expect(p.jobNoun, 'delivery'); + expect(p.workTabLabel, 'Deliveries'); + expect(p.completionVerb, 'Delivered'); + }); + + test('is not the milk run in disguise', () { + const p = ServiceProfile.delivery; + expect(p.collectsCash, isTrue); + expect(p.acceptsPerStop, isTrue); + expect(p.sourceIsKitchen, isFalse); + expect(p.isMilkMan, isFalse); + // And it does not carry the milk run's hinge: START DELIVERY belongs to + // the line that collects a whole load before it drives. + expect(p.startsDeliveryAfterFullLoad, isFalse); + }); + }); +} diff --git a/test/delivery_modes_test.dart b/test/delivery_modes_test.dart new file mode 100644 index 0000000..bd8d16d --- /dev/null +++ b/test/delivery_modes_test.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/consignment_state.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/pickups.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// TWO BACKENDS, ONE BUILD +/// +/// `Collected_By_Miler` and `start-delivery` ship behind a server-side flag +/// that is **off in production today**, so both lifecycles are live at once: +/// +/// old `pickup-complete` releases hyperlocal work itself. The consignment +/// is already `Out_for_Delivery` when it reaches the Deliveries tab and +/// there is nothing for **Start delivery** to call. +/// new the pivot stops at `Collected_By_Miler` and the press is what puts +/// the load on the road. +/// +/// The app must be right in both **without mirroring the flag**, because a +/// mirrored flag is a second source of truth that can be wrong — and would be +/// wrong for every rider for however long it took to ship an app release after +/// the server toggled. So the behaviour is inferred from what the data says: +/// `consignmentstatus` on the row, then the consignment itself, then the +/// `can_start_delivery` / `can_deliver` permissions. +/// +/// These pin both modes, and pin that the old one costs no request. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List sent; + + http.Response jsonRes(Object body, [int code = 200]) => http.Response( + json.encode(body), + code, + headers: const {'content-type': 'application/json'}, + ); + + void stubRoutes(Map routes) { + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + for (final e in routes.entries) { + if (req.url.path.endsWith(e.key)) return e.value; + } + return jsonRes({'success': true}); + }); + } + + setUp(() async { + MockBackend.enabled = false; + SharedPreferences.setMockInitialValues({ + 'userid': 38, + 'authtoken': 'test-token', + }); + await ApiConfig.setToken('test-token'); + ServiceProfile.setActive(ServiceProfile.milkMan); + stubRoutes(const {}); + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + Map stop({String? consignmentStatus}) => { + 'orderid': 'ORDER-1', + 'pickupid': 75, + 'consignmentid': 46, + if (consignmentStatus != null) 'consignmentstatus': consignmentStatus, + 'orderstatus': 'picked', + }; + + group('old backend — the pivot already released the load', () { + test('the press costs no request at all', () async { + stubRoutes(const {}); + final released = await releaseForDelivery( + stop(consignmentStatus: 'Out_for_Delivery'), + ); + + expect(released, isTrue); + expect( + sent, + isEmpty, + reason: + 'a start-delivery the server can only refuse is not a harmless ' + 'no-op — it is a 4xx per rider press in the backend\'s log', + ); + }); + + test('a stop already delivered elsewhere is not re-released', () async { + stubRoutes(const {}); + expect( + await releaseForDelivery(stop(consignmentStatus: 'Delivered')), + isTrue, + ); + expect(sent, isEmpty); + }); + }); + + group('new backend — the rider makes the release', () { + test('a collected consignment is started on the server', () async { + stubRoutes({ + '/start-delivery': jsonRes({ + 'success': true, + 'data': {'status': 'Out_for_Delivery'}, + }), + }); + + expect( + await releaseForDelivery(stop(consignmentStatus: 'Collected_By_Miler')), + isTrue, + ); + expect(sent.single.method, 'POST'); + expect( + sent.single.url.path.endsWith('/miler/consignments/46/start-delivery'), + isTrue, + ); + expect(sent.single.headers['Idempotency-Key'], isNotNull); + }); + + test('a refusal is answered by the consignment, not assumed', () async { + // `INVALID_STATE` means "not Collected_By_Miler" — which covers both a + // real refusal and a consignment another device already released. + stubRoutes({ + '/start-delivery': jsonRes({ + 'success': false, + 'code': 'INVALID_STATE', + 'message': 'consignment is not collected', + }, 400), + '/miler/consignments/46': jsonRes({ + 'success': true, + 'data': {'status': 'Out_for_Delivery'}, + }), + }); + + expect( + await releaseForDelivery(stop(consignmentStatus: 'Collected_By_Miler')), + isTrue, + reason: 'already on the road is not a reason to block the rider', + ); + }); + + test('a genuine refusal does not fake a release', () async { + stubRoutes({ + '/start-delivery': jsonRes({ + 'success': false, + 'code': 'INVALID_STATE', + 'message': 'consignment is at the hub', + }, 400), + '/miler/consignments/46': jsonRes({ + 'success': true, + 'data': {'status': 'Inwarded_at_Hub'}, + }), + }); + + expect( + await releaseForDelivery(stop(consignmentStatus: 'Collected_By_Miler')), + isFalse, + reason: + 'showing ACTIVE for a consignment the hub still holds is the ' + 'fiction that made deliver fail at the door with nothing on ' + 'screen to explain it', + ); + }); + }); + + group('a row that says nothing falls back to asking', () { + test('the consignment is read when the row carries no status', () async { + stubRoutes({ + '/miler/consignments/46': jsonRes({ + 'success': true, + 'data': {'status': 'Out_for_Delivery'}, + }), + }); + + expect(await releaseForDelivery(stop()), isTrue); + expect(sent.single.method, 'GET'); + expect(sent.single.url.path.endsWith('/miler/consignments/46'), isTrue); + }); + + test('the permission flags answer when the word does not', () { + // A status this build has never heard of, plus the server's own verdict + // on what may be done with it. + expect( + ConsignmentGate.stateFromDetailForTest({ + 'status': 'Handed_To_Locker_Bank', + 'can_deliver': true, + }), + ConsignmentState.outForDelivery, + ); + expect( + ConsignmentGate.stateFromDetailForTest({ + 'status': 'Awaiting_Rider_Departure', + 'can_start_delivery': true, + }), + ConsignmentState.collectedByMiler, + ); + }); + + test('no consignment id is not a release', () async { + expect(await releaseForDelivery({'orderid': 'ORDER-9'}), isFalse); + }); + }); + + group('a skipped stop and the server agree about whether it is open', () { + test('a consignment the hub still calls out-for-delivery stays open', () { + expect(ConsignmentState.outForDelivery.isOpenAfterSkip, isTrue); + expect(ConsignmentState.collectedByMiler.isOpenAfterSkip, isTrue); + }); + + test('a consignment the hub closed is closed', () { + expect(ConsignmentState.delivered.isOpenAfterSkip, isFalse); + expect(ConsignmentState.rtoInitiated.isOpenAfterSkip, isFalse); + expect(ConsignmentState.returnedToSender.isOpenAfterSkip, isFalse); + expect(ConsignmentState.cancelled.isOpenAfterSkip, isFalse); + }); + + test('a read that failed is not permission to close a stop', () { + expect( + ConsignmentState.unknown.isOpenAfterSkip, + isTrue, + reason: + 'a terminal local record over a consignment the hub still ' + 'calls open is two systems disagreeing about whose problem a ' + 'parcel is, with only the rider\'s screen saying "nobody\'s"', + ); + }); + }); +} diff --git a/test/delivery_proof_test.dart b/test/delivery_proof_test.dart new file mode 100644 index 0000000..065ae42 --- /dev/null +++ b/test/delivery_proof_test.dart @@ -0,0 +1,165 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/proof_store.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PROOF OF DELIVERY +/// +/// A photo taken at a door is evidence, and evidence that disappears is worse +/// than none — it makes the rider believe he has cover he does not have. +/// These pin the two properties that make it real: it outlives the OS cache +/// `ImagePicker` hands back, and it belongs to the session that took it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + late Directory docs; + + setUp(() async { + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userid': 38}); + + // path_provider has no host under the test binding. + docs = await Directory.systemTemp.createTemp('proof_docs'); + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => docs.path, + ); + }); + + tearDown(() async { + ServiceProfile.setActive(ServiceProfile.parcel); + if (await docs.exists()) await docs.delete(recursive: true); + }); + + /// A real 1×1 PNG. The store tests never decode it, but the widget test + /// does: `Image.file` on 64 bytes of nonsense never completes its frame, so + /// `pumpAndSettle` waits for a decode that will never land and the test + /// hangs for the full ten minutes rather than failing. + const List onePixelPng = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, + ]; + + /// Stands in for what the camera hands back: a file in a volatile place. + Future cameraShot(String name) async { + final cache = await Directory.systemTemp.createTemp('picker_cache'); + final f = File('${cache.path}/$name.jpg'); + await f.writeAsBytes(onePixelPng); + return f; + } + + group('the store', () { + test('the photo is copied out of the camera cache', () async { + final shot = await cameraShot('a'); + final saved = await ProofStore.save('ORDER-1', shot.path); + + expect(saved, isNotNull); + expect( + saved, + isNot(shot.path), + reason: + 'ImagePicker returns an OS cache path the system reclaims — ' + 'a record pointing at it loses its evidence', + ); + expect(File(saved!).existsSync(), isTrue); + + // The original can vanish and the proof survives, which is the point. + await shot.parent.delete(recursive: true); + expect(File(saved).existsSync(), isTrue); + expect(await ProofStore.pathFor('ORDER-1'), saved); + }); + + test('a retake replaces rather than accumulates', () async { + final first = await ProofStore.save( + 'ORDER-1', + (await cameraShot('a')).path, + ); + final second = await ProofStore.save( + 'ORDER-1', + (await cameraShot('b')).path, + ); + expect(second, first, reason: 'one stop, one proof'); + expect(await ProofStore.pathFor('ORDER-1'), second); + }); + + test('a proof whose file has gone is not proof', () async { + final saved = await ProofStore.save( + 'ORDER-1', + (await cameraShot('a')).path, + ); + await File(saved!).delete(); + expect( + await ProofStore.pathFor('ORDER-1'), + isNull, + reason: 'returning a dead path would draw a broken image as evidence', + ); + }); + + test('nothing is invented from nothing', () async { + expect(await ProofStore.save('', 'whatever'), isNull); + expect(await ProofStore.save('ORDER-1', ''), isNull); + expect(await ProofStore.save('ORDER-1', '/no/such/file.jpg'), isNull); + expect(await ProofStore.pathFor('ORDER-NONE'), isNull); + }); + + test('a device path is never offered as a remote url', () async { + await ProofStore.save('ORDER-1', (await cameraShot('a')).path); + expect( + await ProofStore.remoteUrlFor('ORDER-1'), + '', + reason: + 'the Miler API has no upload route; a filesystem path written ' + "into the hub's photourl would look like evidence and resolve to " + 'nothing', + ); + }); + }); + + group('ownership', () { + test("another rider cannot open this rider's doorstep photos", () async { + await ProofStore.save('ORDER-1', (await cameraShot('a')).path); + expect(await ProofStore.pathFor('ORDER-1'), isNotNull); + + SharedPreferences.setMockInitialValues({'userid': 99}); + expect( + await ProofStore.pathFor('ORDER-1'), + isNull, + reason: 'proof is scoped like every other record — see WorkScope', + ); + }); + + test('the other line cannot either', () async { + await ProofStore.save('ORDER-1', (await cameraShot('a')).path); + ServiceProfile.setActive(ServiceProfile.parcel); + expect(await ProofStore.pathFor('ORDER-1'), isNull); + }); + + test('logout deletes the photos, not just the index', () async { + final saved = await ProofStore.save( + 'ORDER-1', + (await cameraShot('a')).path, + ); + await ProofStore.clearScope(); + + expect(await ProofStore.pathFor('ORDER-1'), isNull); + expect( + File(saved!).existsSync(), + isFalse, + reason: "a doorstep photo must not outlive the session that took it", + ); + }); + }); +} diff --git a/test/delivery_queue_test.dart b/test/delivery_queue_test.dart new file mode 100644 index 0000000..a395ce8 --- /dev/null +++ b/test/delivery_queue_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE DELIVERY QUEUE, AND THE BAG THAT SURVIVES IT +/// +/// Two promises are made at a kitchen counter and both have to hold all the way +/// to a doorstep: +/// +/// 1. **Five orders picked is five deliveries** — no order goes missing in the +/// hand-off, and nothing appears that was not collected. +/// 2. **Joe is Bag 1 and stays Bag 1** — even after Joe is delivered and the +/// list underneath him shrinks. +/// +/// The second one is the subtle one, and it is why bag identity is *stored* at +/// pickup rather than recomputed from whatever is on screen. A position-derived +/// bag number silently renames every remaining order the moment one leaves the +/// list, which sends the rider looking for a bag that is already in somebody's +/// hallway. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map order(String id, String customer) => { + 'orderid': id, + 'pickupcustomer': customer, + 'kitchenname': 'Vidhya Kitchen', + 'kitchenid': 'K1', + 'quantity': 3, + }; + + final five = [ + order('1', 'Joe'), + order('2', 'Arun'), + order('3', 'Priya'), + order('4', 'Kumar'), + order('5', 'Ravi'), + ]; + + group('the hand-off', () { + test( + 'five picked orders become five deliveries, each with its bag', + () async { + ServiceProfile.setActive(ServiceProfile.meals); + + // What the pickup confirmation writes when the slide lands. + final manifest = BagManifest.forGroup(five); + await addCollectedOrderIds([for (final l in manifest) l.orderId]); + await saveBagLabels({for (final l in manifest) l.orderId: l.bag}); + + final carrying = await getCollectedOrderIds(); + final bags = await getBagLabels(); + + expect(carrying.length, 5, reason: 'five picked is five to deliver'); + expect(bags.length, 5, reason: 'and five bags, one each'); + expect(bags['1'], 'Bag 1'); + expect(bags['5'], 'Bag 5'); + // No aggregate anywhere: the count of deliveries and the count of bags + // are the same set, not two numbers that can drift. + expect(bags.keys.toSet(), carrying); + }, + ); + + test('a bag the counter could not supply never becomes a delivery', () async { + ServiceProfile.setActive(ServiceProfile.meals); + + // Four handed over, one short — the shortfall path the pickup sheet owns. + final loaded = five.where((o) => o['orderid'] != '3').toList(); + await addCollectedOrderIds([ + for (final o in loaded) o['orderid'] as String, + ]); + await saveBagLabels({ + for (final l in BagManifest.forGroup(loaded)) l.orderId: l.bag, + }); + await addNotLoadedOrderIds(['3']); + + expect(await getCollectedOrderIds(), {'1', '2', '4', '5'}); + expect(await getNotLoadedOrderIds(), {'3'}); + // A short pick is its own record. It is not a skip: nobody was visited + // and the customer did nothing. + expect(await getSkippedOrderIds(), isEmpty); + // And it is not carrying a bag, because it was never in the box. + expect((await getBagLabels()).containsKey('3'), isFalse); + }); + }); + + group('bag identity outlives the queue', () { + test('delivering the first does not renumber the rest', () async { + ServiceProfile.setActive(ServiceProfile.meals); + await saveBagLabels({ + for (final l in BagManifest.forGroup(five)) l.orderId: l.bag, + }); + + // Joe is delivered and leaves the carrying set. + await addCollectedOrderIds([ + for (final o in five) o['orderid'] as String, + ]); + await removeCollectedOrderIds(['1']); + + final bags = await getBagLabels(); + expect(await getCollectedOrderIds(), {'2', '3', '4', '5'}); + // Arun does *not* become Bag 1 because the list in front of him shrank. + expect(bags['2'], 'Bag 2'); + expect(bags['3'], 'Bag 3'); + expect(bags['5'], 'Bag 5'); + }); + + test('a second kitchen adds to the map rather than replacing it', () async { + ServiceProfile.setActive(ServiceProfile.meals); + await saveBagLabels({ + for (final l in BagManifest.forGroup(five.take(2).toList())) + l.orderId: l.bag, + }); + + // A different counter, its own Bag 1 and Bag 2. + final second = [order('9', 'Meena'), order('10', 'Sai')]; + await saveBagLabels({ + for (final l in BagManifest.forGroup(second)) l.orderId: l.bag, + }); + + final bags = await getBagLabels(); + expect(bags.length, 4, reason: 'the first load must survive the second'); + expect(bags['1'], 'Bag 1'); + expect(bags['9'], 'Bag 1', reason: 'each counter counts its own shelf'); + expect(bags['10'], 'Bag 2'); + }); + + test('a skipped delivery keeps its bag so it can be resumed', () async { + ServiceProfile.setActive(ServiceProfile.meals); + await saveBagLabels({ + for (final l in BagManifest.forGroup(five)) l.orderId: l.bag, + }); + await addSkippedBooking(five[2], reason: 'Customer unavailable'); + + expect(await getSkippedOrderIds(), contains('3')); + // And the reason survives with it — the hub needs to know why, and + // "Failed" is not a reason. + final skipped = await getSkippedBookings(); + expect( + skipped.firstWhere((b) => b['orderid'] == '3')['skipreason'], + 'Customer unavailable', + ); + // He is still carrying Priya's lunch — the bag identity is exactly what + // he needs when he comes back to it. + expect((await getBagLabels())['3'], 'Bag 3'); + }); + + test("a day's run is wiped so bags never leak into tomorrow", () async { + await saveBagLabels({'1': 'Bag 1'}); + await clearServiceRunState(); + expect(await getBagLabels(), isEmpty); + }); + }); + + test('a demo bag never outlives the build that wrote it', () async { + // The mock day's ids are `MOCK-…` so the launch purge can find them. The + // bag map is keyed by order id, so it is swept by the same rule — a stored + // pairing is trusted later *because* it is stored, and a stale one is worse + // than none. + await saveBagLabels({'MOCK-M-1001': 'Bag 1', '77': 'Bag 2'}); + await purgeDemoRecords(); + + final bags = await getBagLabels(); + expect(bags.containsKey('MOCK-M-1001'), isFalse); + expect(bags['77'], 'Bag 2', reason: 'real work is untouched'); + }); +} diff --git a/test/delivery_shell_smoke_test.dart b/test/delivery_shell_smoke_test.dart new file mode 100644 index 0000000..c25030f --- /dev/null +++ b/test/delivery_shell_smoke_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/helpers/rider_shell.dart'; + +/// Does the ported delivery dashboard actually *build*? +/// +/// ── Why this is worth a test of its own ── +/// +/// `delivery_line_test.dart` checks that [riderShell] returns the right widget +/// *type*, which is a cheap assertion: it constructs nothing. The failure this +/// port was most exposed to is one type-checking cannot see — the delivery home +/// page resolves its controller in a **field initialiser**: +/// +/// final DeliveryController _deliveryController = Get.find(); +/// +/// GetX keys its container on type, and both lines declare a class called +/// `RiderLogController`, `LogController` and `ProfileController`. So a delivery +/// screen asking for "the" RiderLogController gets nothing unless the *xpress* +/// type was registered — and it throws while the widget is being constructed, +/// before any frame, which a rider sees as a red screen rather than an empty +/// list. See `bootstrapDeliveryLine`. +/// +/// Pumping the shell for real is the only thing that catches that. +/// +/// ── Why there is no matching test for the parcel shell ── +/// +/// The parcel dashboard reads `PickupController` and friends out of the GetX +/// container too, but nothing registers them for it — `main()` does, and tests +/// do not run `main()`. Pumping it here would be testing this file's ability to +/// re-implement `main()`, not the app. +/// +/// The delivery shell needs no such scaffolding, and that asymmetry is the +/// point: [riderShell] calls `registerDeliveryLine()` on the way in, precisely +/// because a rider can reach it mid-session by signing out of one line and into +/// the other. This test passing with no setup *is* that guard working — and it +/// is what caught the first version of that call being `async`, which registered +/// nothing before the shell built. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // ── What is allowed to have gone wrong ── + // + // One thing, inherited with the markup: Flutter's debug-only complaint that a + // `ListTile` sits inside a coloured `DecoratedBox`, so its ink splash is + // hidden. Cosmetic, debug-only, and present in Xpress-rider before the port. + // + // Filtered at `FlutterError.onError` rather than drained afterwards with + // `takeException`, which returns one exception at a time while the harness + // fails the test on the whole batch. Everything else still reaches the + // default handler and still fails the test — in particular a `Get.find` + // failure or a missing plugin, which would mean the port does not run. + // + // Installed **inside the test body**, not in `setUp`: the test binding + // installs its own reporter when it starts running the body, which is after + // `setUp`, so a handler set there is overwritten before the first pump. + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + testWidgets('the delivery dashboard builds and shows its four tabs', ( + tester, + ) async { + final reportToHarness = FlutterError.onError; + FlutterError.onError = (details) { + if (details.exceptionAsString().contains( + 'ListTile background color or ink splashes', + )) { + return; + } + reportToHarness?.call(details); + }; + addTearDown(() => FlutterError.onError = reportToHarness); + + SharedPreferences.setMockInitialValues({}); + // ── Set directly, not resolved ── + // + // No tenant name or id opens this line any more; only + // `--dart-define=TENANT=delivery` does, and that is a compile-time constant + // a test cannot set. See `delivery_line_test.dart` for why the wire route + // was closed. + // + // What this test is actually for survives that unchanged: it is the guard + // on `registerDeliveryLine()` being **synchronous**. An async version + // returns at its first await, the framework builds the shell in the gap, + // and the home page throws on a controller that is about to exist. Only + // pumping the real shell catches it. + ServiceProfile.setActive(ServiceProfile.delivery); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp(home: riderShell()), + ), + ); + await tester.pump(); + + // The ported Xpress-rider bar, verbatim: four fixed tabs, shouted in caps. + // If the controllers had not been registered, none of this would exist — + // the page would have thrown on construction. + expect(find.text('HOME'), findsOneWidget); + expect(find.text('DELIVERIES'), findsOneWidget); + expect(find.text('SUMMARY'), findsOneWidget); + expect(find.text('PROFILE'), findsOneWidget); + + // And it is not the parcel bar wearing different words. + expect(find.text('Activity'), findsNothing); + expect(find.text('Bookings'), findsNothing); + }); +} diff --git a/test/demo_bookings_test.dart b/test/demo_bookings_test.dart deleted file mode 100644 index 7bfc770..0000000 --- a/test/demo_bookings_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:miler/Models/stop_status.dart'; -import 'fixtures/demo_stops.dart'; - -/// Injecting demo stops into Bookings is only half the fix. The tab drops -/// anything still pending — it shows work the rider has *taken on* — so demo -/// stops that arrive as `status: 'new'` are filtered out silently and the tab -/// stays empty anyway. These tests pin the states, because a regression there -/// looks exactly like "the mock data doesn't work" with nothing in the logs. -void main() { - /// The predicate Bookings uses to decide a stop belongs to it at all - /// (pickups.dart — anything still pending lives on Home instead). - bool survivesBookingsFilter(Map o) { - final status = (o['orderstatus']?.toString().toLowerCase() ?? '').trim(); - const pending = {'', 'assigned', 'miler_assigned', 'pending', 'new'}; - return !pending.contains(status); - } - - test('every demo booking survives the accepted-only filter', () { - final bookings = getDemoBookings(riderId: 4021); - expect(bookings, isNotEmpty); - expect( - bookings.where(survivesBookingsFilter).length, - bookings.length, - reason: 'a demo stop left as "new" is dropped and never reaches the list', - ); - }); - - test('covers every state the tab can render', () { - final statuses = getDemoBookings(riderId: 4021) - .map((b) => stopStatusFromRaw(b['orderstatus'])) - .toSet(); - - // Two finished ones give the progress bar something green; the active one - // drives the banner and blocks the rest; the skipped one is the amber - // return-visit case. - expect(statuses, contains(StopStatus.picked)); - expect(statuses, contains(StopStatus.active)); - expect(statuses, contains(StopStatus.skipped)); - expect(statuses, contains(StopStatus.accepted)); - }); - - test('leaves enough open stops to fill the list', () { - // Bookings excludes active and finished stops from the card list — those - // go to the banner and the progress count. What is left has to be more - // than a couple of cards or the tab still looks broken. - final open = getDemoBookings(riderId: 4021).where((b) { - final st = stopStatusFromRaw(b['orderstatus']); - return st != StopStatus.active && - st != StopStatus.picked && - st != StopStatus.cancelled; - }).length; - expect(open, greaterThanOrEqualTo(8)); - }); - - test('is the same route Home gave the miler', () { - final home = getDemoTripStops(riderId: 4021).take(12); - final bookings = getDemoBookings(riderId: 4021); - expect( - bookings.map((b) => b['orderid']), - home.map((b) => b['orderid']), - reason: 'Bookings must not invent a second, unrelated route', - ); - }); - - pipelineTests(); - - test('two milers get different bookings', () { - final a = getDemoBookings(riderId: 4021).map((b) => b['orderid']).toSet(); - final b = getDemoBookings(riderId: 4022).map((b) => b['orderid']).toSet(); - expect(a.intersection(b), isEmpty); - }); -} - -/// The first fix for the empty Bookings tab was gated on the RAW api response -/// (`items.isEmpty`), but the tab renders a list that survives two more -/// filters. A signed-in miler's backend answer is rarely empty — it is full of -/// ASSIGNED bookings, which belong on Home and are filtered out here — so the -/// fallback never fired and the tab stayed blank. These tests pin the end of -/// the pipeline, using the real production helpers, not a paraphrase of them. -void pipelineTests() { - /// Bookings' accepted-only predicate. - List> keepWorkable( - List> items, - Set acceptedIds, - ) => - items.where((o) { - final id = (o['orderid'] ?? '').toString(); - if (id.isNotEmpty && acceptedIds.contains(id)) return true; - final status = - (o['orderstatus']?.toString().toLowerCase() ?? '').trim(); - const pending = {'', 'assigned', 'miler_assigned', 'pending', 'new'}; - return !pending.contains(status); - }).toList(); - - test('a backend full of ASSIGNED bookings still leaves the tab empty', () { - // What the live backend actually returns for a seeded miler. - final fromApi = [ - for (var i = 0; i < 6; i++) - {'orderid': 'API-$i', 'orderstatus': 'miler_assigned'}, - ]; - expect(fromApi, isNotEmpty, reason: 'the raw response is NOT empty'); - expect(keepWorkable(fromApi, {}), isEmpty, - reason: 'yet nothing survives — which is why the old gate never fired'); - }); - - test('the fallback keyed on the filtered list produces visible cards', () { - final fromApi = [ - for (var i = 0; i < 6; i++) - {'orderid': 'API-$i', 'orderstatus': 'miler_assigned'}, - ]; - - var workable = keepWorkable(fromApi, {}); - if (workable.isEmpty) workable = getDemoBookings(riderId: 4021); - - // Bookings' list excludes active and finished stops — those go to the - // banner and the progress count. - final listForTop = workable.where((o) { - final st = stopStatusFromRaw(o['orderstatus']); - return st != StopStatus.active && - st != StopStatus.picked && - st != StopStatus.cancelled; - }).toList(); - - expect(listForTop, isNotEmpty, reason: 'the rider must see cards'); - expect(listForTop.length, greaterThanOrEqualTo(8)); - }); -} diff --git a/test/demo_purge_test.dart b/test/demo_purge_test.dart new file mode 100644 index 0000000..393d7af --- /dev/null +++ b/test/demo_purge_test.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/accepted_store.dart'; + +/// Deleting a mock generator does not reach the rows it already wrote. +/// +/// Every store in `accepted_store.dart` is SharedPreferences, so a phone that +/// once ran a build with the demo layer still carries those bookings — and +/// Bookings merges the accepted store into its list while Activity reads the +/// completed and skipped ones. That is why demo stops kept appearing on exactly +/// those two tabs with nothing left in the repo to explain them. +/// +/// The risk in fixing it is the obvious one: a blanket wipe would take the +/// rider's real accepted work with it. So these tests are mostly about what +/// must *survive*. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Map booking(String id, {String? pickupId}) => { + 'orderid': id, + if (pickupId != null) 'pickupid': pickupId, + 'pickupcustomer': 'Customer $id', + 'orderstatus': 'accepted', + }; + + test('drops the demo bookings and keeps the real ones', () async { + SharedPreferences.setMockInitialValues({}); + await debugSeedStore( + 'mock_accepted_bookings', + jsonEncode([ + booking('MOCK-Q-1001', pickupId: 'del-mock-q-1001'), + booking('884213'), + booking('MOCK-B-3004'), + ]), + ); + + expect(await purgeDemoRecords(), 2); + + final left = await getAcceptedBookings(); + expect(left.map((b) => b['orderid']), ['884213']); + }); + + test('matches on the pickup id too', () async { + // `getMockQueues` wrote ids like `del-mock-q-1001` on the pickup field + // while the order id looked ordinary in some rows. + SharedPreferences.setMockInitialValues({}); + await debugSeedStore( + 'completed_bookings', + jsonEncode([ + booking('7001', pickupId: 'del-mock-d-2003'), + booking('7002', pickupId: 'del-77aa'), + ]), + ); + + expect(await purgeDemoRecords(), 1); + + // Read the raw store, not [getCompletedBookings]: that reader also drops + // anything not stamped today, which would empty this list for a reason + // that has nothing to do with the purge. + final prefs = await SharedPreferences.getInstance(); + final left = + (jsonDecode(prefs.getString(await debugStoreKey('completed_bookings'))!) + as List) + .map((e) => (e as Map)['orderid']) + .toList(); + expect(left, ['7002']); + }); + + test('clears the id-only stores as well', () async { + SharedPreferences.setMockInitialValues({}); + await debugSeedStore('mock_rejected_order_ids', ['MOCK-Q-1005', '99120']); + await debugSeedStore('collected_order_ids', ['MOCK-B-3001', '77451']); + await debugSeedStore('not_loaded_orders', ['DEMO-2', '31009']); + + expect(await purgeDemoRecords(), 3); + expect(await getRejectedOrderIds(), {'99120'}); + expect(await getCollectedOrderIds(), {'77451'}); + expect(await getNotLoadedOrderIds(), {'31009'}); + }); + + test('a real order id is never mistaken for a demo one', () async { + // The ids a backend actually issues. None of these may be touched — losing + // a genuine accepted booking is a rider driving to a door with no record of + // why, which is far worse than a leftover demo row. + final real = [ + booking('884213'), + booking('ORD-2026-000412'), + booking('DM-99120', pickupId: 'del-77aa'), + booking('mockingbird-77'), // contains "mock", does not start with it + ]; + SharedPreferences.setMockInitialValues({}); + await debugSeedStore('mock_accepted_bookings', jsonEncode(real)); + + expect(await purgeDemoRecords(), 0); + expect((await getAcceptedBookings()).length, real.length); + }); + + test('an untouched install is a no-op', () async { + SharedPreferences.setMockInitialValues({}); + expect(await purgeDemoRecords(), 0); + }); +} diff --git a/test/device_id_test.dart b/test/device_id_test.dart new file mode 100644 index 0000000..1c0c3ed --- /dev/null +++ b/test/device_id_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/utils/device.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE SIGN-IN MUST NOT DIE ON A FIELD IT DOES NOT SEND +/// +/// `ensureDeviceId` threw on every unhappy path, and it is awaited on the line +/// before the PIN check posts — inside the sign-in's outer `try`. So the throw +/// was caught as "Could not reach the server" before a single byte left the +/// phone, and the MPIN screen then reported it as **"Incorrect MPIN"**. For +/// every phone number, on every attempt. +/// +/// It threw for a guaranteed reason, not a rare one: it asked for +/// `androidInfo` unconditionally, which raises on iOS. +/// +/// There are no platform channels in a widget test, so `device_info_plus` +/// raises `MissingPluginException` here — which makes this file an exact +/// reproduction of the iOS path. Under the old code every test below throws. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('hands back an id even when the platform cannot supply one', () async { + final prefs = await SharedPreferences.getInstance(); + + final id = await DeviceUtils.ensureDeviceId(prefs); + + expect(id, isNotEmpty, reason: 'a sign-in cannot wait for a device id'); + expect( + id, + startsWith(DeviceUtils.unknownDeviceId), + reason: 'a generated id says so, so a log can be read honestly', + ); + }); + + test('the id is stable across calls — that is the whole job', () async { + final prefs = await SharedPreferences.getInstance(); + + final first = await DeviceUtils.ensureDeviceId(prefs); + final second = await DeviceUtils.ensureDeviceId(prefs); + + expect(second, first); + expect(prefs.getString('deviceId'), first); + }); + + test('a stored hardware id is never replaced by a generated one', () async { + SharedPreferences.setMockInitialValues({'deviceId': 'real-hardware-id'}); + final prefs = await SharedPreferences.getInstance(); + + expect(await DeviceUtils.ensureDeviceId(prefs), 'real-hardware-id'); + }); + + test('two installs do not collide', () async { + final prefs = await SharedPreferences.getInstance(); + final a = await DeviceUtils.ensureDeviceId(prefs); + + SharedPreferences.setMockInitialValues({}); + final b = await DeviceUtils.ensureDeviceId( + await SharedPreferences.getInstance(), + ); + + expect(a, isNot(b)); + }); + + test('the push token degrades to empty rather than throwing', () async { + // Same contract, for the same reason: a rider who declined notifications + // still has a shift to start. + final prefs = await SharedPreferences.getInstance(); + expect(await DeviceUtils.ensureFcmToken(prefs), ''); + }); +} diff --git a/test/done_handoff_test.dart b/test/done_handoff_test.dart index 3f5bd97..e22eb20 100644 --- a/test/done_handoff_test.dart +++ b/test/done_handoff_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -93,7 +94,9 @@ void main() { expect(hero, greaterThan(sizeOf(tester, 'NEXT STOP'))); expect( hero, - greaterThan(sizeOf(tester, '78, Cross Cut Road, Gandhipuram, Coimbatore')), + greaterThan( + sizeOf(tester, '78, Cross Cut Road, Gandhipuram, Coimbatore'), + ), ); expect(hero, greaterThan(sizeOf(tester, 'Green Leaf Grocers'))); expect(hero, greaterThan(sizeOf(tester, 'Delivered · 3 stops left'))); @@ -104,27 +107,36 @@ void main() { expect(hero / sizeOf(tester, 'Green Leaf Grocers'), greaterThan(1.6)); }); - testWidgets('finished work is quiet — grey text, green only in the tick', ( + // ── Finished work confirms, and then gets out of the way ── + // + // The status line was 12.5sp grey with a 16sp tick — the same treatment as + // every other caption on the screen, so the one line confirming the act the + // rider had just performed read as metadata about it. He does need to + // confirm it registered; that is the entire reason the line exists. + // + // It is a tinted capsule now, in its semantic colour — the app's own state + // vocabulary, shared with the route chips and the confirm sheet. What has NOT + // changed is the ranking this test was really protecting: the capsule is + // short and set at 13.5sp, and the next stop underneath is 28sp near-black. + testWidgets('finished work confirms without out-ranking the next stop', ( tester, ) async { await pumpDone(tester); final status = tester.widget(find.text('Delivered · 3 stops left')); - // Not the success colour. "Delivered" is something the rider did himself - // thirty seconds ago; he needs to be able to confirm it, not be told it. - expect(status.style!.color, ColorConstants.secondaryText); + expect(status.style!.color, ColorConstants.acceptGreen); + expect(status.style!.fontSize, lessThan(16)); - // The tick is the whole of the green. final tick = tester.widget( - find.byIcon(Icons.check_circle_rounded).first, + find.byIcon(LucideIcons.circleCheck).first, ); expect(tick.color, ColorConstants.acceptGreen); - // The live destination, by contrast, is near-black. - expect( - tester.widget(find.text('Sundar Textiles')).style!.color, - ColorConstants.slateText, - ); + // The live destination is still the thing the eye lands on: near-black, and + // twice the size of the confirmation above it. + final next = tester.widget(find.text('Sundar Textiles')); + expect(next.style!.color, ColorConstants.slateText); + expect(next.style!.fontSize, greaterThan(status.style!.fontSize! * 1.8)); }); testWidgets('nothing in the body is boxed', (tester) async { @@ -168,7 +180,7 @@ void main() { } expect(find.text('2'), findsOneWidget); expect(find.text('3'), findsOneWidget); - expect(find.byIcon(Icons.chevron_right_rounded), findsNWidgets(2)); + expect(find.byIcon(LucideIcons.chevronRight), findsNWidgets(2)); }); testWidgets('the stop type is a label, not a filled badge', (tester) async { diff --git a/test/duty_sheet_test.dart b/test/duty_sheet_test.dart index 967f0ce..34cdc42 100644 --- a/test/duty_sheet_test.dart +++ b/test/duty_sheet_test.dart @@ -47,31 +47,47 @@ void main() { await pump(tester, goingOnline: true); expect(find.text('Go on duty?'), findsOneWidget); - // The strip shows the move: leaving Off duty, arriving at On duty. - expect(find.text('Off duty'), findsOneWidget); - expect(find.text('On duty'), findsOneWidget); - expect(find.text('Slide to go on duty'), findsOneWidget); - // Duty is not connectivity. + expect(find.text('Go on duty'), findsOneWidget); + expect(find.text('Not yet'), findsOneWidget); + + // Duty is not connectivity. A rider reading a wifi symbol on a screen that + // stops his work is being told the app has a network problem. expect(find.byIcon(Icons.wifi), findsNothing); expect(find.byIcon(Icons.wifi_off), findsNothing); }); - testWidgets('going off duty lists what stops, including the refusal', ( + // ── The consequence survives; the three rows that carried it do not ── + // + // The sheet listed what stops as three icon-and-text rows. The facts are the + // contract, not the rows — and the one that matters most is the refusal, + // which the original sheet sprang on the rider only *after* he had completed + // a slide. It is in the sentence now, before he decides. + testWidgets('going off duty states what stops, including the refusal', ( tester, ) async { await pump(tester, goingOnline: false); expect(find.text('Go off duty?'), findsOneWidget); - expect(find.text('No new trips until you are back on'), findsOneWidget); - expect(find.text('Live tracking pauses'), findsOneWidget); - // The one the old sheet sprang on him only after he had slid it. + + final body = tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + .join(' '); + expect(body, contains('stops assigning you trips')); + expect(body, contains('live tracking pauses')); expect( - find.text('Any booking you are holding must be finished first'), - findsOneWidget, + body, + contains('has to be finished first'), + reason: 'the refusal must be stated before the gesture, not after it', ); }); - testWidgets('a completed slide runs the toggle', (tester) async { + // ── Slides are reserved for money ── + // + // The confirmation was a slide-to-submit borrowed from the payment flows. + // Ending a shift is not a transaction, and asking for a drag made the + // commonest action of the rider's day the most laborious one. + testWidgets('confirming runs the toggle', (tester) async { var calls = 0; await pump( tester, @@ -82,19 +98,14 @@ void main() { }, ); - // The knob, not the arrow in the state strip above it. - final knob = find.descendant( - of: find.byType(SlideToSubmit), - matching: find.byIcon(Icons.arrow_forward_rounded), - ); - await tester.drag(knob, const Offset(400, 0)); + await tester.tap(find.widgetWithText(ElevatedButton, 'Go on duty')); await tester.pump(); await tester.pump(); expect(calls, 1); }); - testWidgets('a short slide does not', (tester) async { + testWidgets('dismissing does not', (tester) async { var calls = 0; await pump( tester, @@ -105,14 +116,10 @@ void main() { }, ); - final knob = find.descendant( - of: find.byType(SlideToSubmit), - matching: find.byIcon(Icons.arrow_forward_rounded), - ); - await tester.drag(knob, const Offset(40, 0)); + await tester.tap(find.text('Stay on duty')); await tester.pump(); await tester.pump(); - expect(calls, 0, reason: 'ending the day takes a deliberate gesture'); + expect(calls, 0, reason: 'the way out must not perform the action'); }); } diff --git a/test/failures/mode_dark_isolatedDiff.png b/test/failures/mode_dark_isolatedDiff.png deleted file mode 100644 index f318d4e..0000000 Binary files a/test/failures/mode_dark_isolatedDiff.png and /dev/null differ diff --git a/test/failures/mode_dark_maskedDiff.png b/test/failures/mode_dark_maskedDiff.png deleted file mode 100644 index 7398d99..0000000 Binary files a/test/failures/mode_dark_maskedDiff.png and /dev/null differ diff --git a/test/failures/mode_dark_masterImage.png b/test/failures/mode_dark_masterImage.png deleted file mode 100644 index 3d8b2fa..0000000 Binary files a/test/failures/mode_dark_masterImage.png and /dev/null differ diff --git a/test/failures/mode_dark_testImage.png b/test/failures/mode_dark_testImage.png deleted file mode 100644 index 30d8999..0000000 Binary files a/test/failures/mode_dark_testImage.png and /dev/null differ diff --git a/test/fixtures/demo_stops.dart b/test/fixtures/demo_stops.dart deleted file mode 100644 index 9afb522..0000000 --- a/test/fixtures/demo_stops.dart +++ /dev/null @@ -1,842 +0,0 @@ - -// Shared mock booking data for development/testing - -List> getMockQueues() { - final now = DateTime.now(); - return [ - { - 'orderid': 'MOCK-Q-1001', - 'pickupid': 'del-mock-q-1001', - 'orderheaderid': '10001', - 'orderstatus': 'accepted', - 'pickupcustomer': 'Priya Sharma', - 'pickupaddress': '45, Lake View Apartments, Race Course', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9558', - 'pickuplat': '11.0243', - 'pickuplong': '76.9652', - 'kms': '2.1', - 'Quantity': '3', - 'notes': 'Documents parcel - handover at door', - 'expected_pickup_time': now - .add(const Duration(minutes: 15)) - .toIso8601String(), - 'riderslat': '11.0180', - 'riderslon': '76.9580', - }, - { - 'orderid': 'MOCK-Q-1002', - 'pickupid': 'del-mock-q-1002', - 'orderheaderid': '10002', - 'orderstatus': '', - 'pickupcustomer': 'Arun Venkatesh', - 'pickupaddress': '23, Teachers Colony, Saibaba Colony', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9678', - 'pickuplat': '11.0312', - 'pickuplong': '76.9721', - 'kms': '1.5', - 'Quantity': '8', - 'notes': 'Clothes & accessories - packed in bags', - 'expected_pickup_time': now - .add(const Duration(minutes: 30)) - .toIso8601String(), - 'riderslat': '11.0180', - 'riderslon': '76.9580', - }, - { - 'orderid': 'MOCK-Q-1003', - 'pickupid': 'del-mock-q-1003', - 'orderheaderid': '10003', - 'orderstatus': '', - 'pickupcustomer': 'Meena Krishnan', - 'pickupaddress': '5/2, North Avenue, Vadavalli', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9902', - 'pickuplat': '11.0358', - 'pickuplong': '76.9985', - 'kms': '3.2', - 'Quantity': '12', - 'notes': 'Electronics item - handle with care', - 'expected_pickup_time': now - .add(const Duration(minutes: 45)) - .toIso8601String(), - 'riderslat': '11.0180', - 'riderslon': '76.9580', - }, - { - 'orderid': 'MOCK-Q-1004', - 'pickupid': 'del-mock-q-1004', - 'orderheaderid': '10004', - 'orderstatus': 'accepted', - 'pickupcustomer': 'Vikram Singh', - 'pickupaddress': '88, Royal Nagar, Singanallur', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9428', - 'pickuplat': '11.0198', - 'pickuplong': '76.9512', - 'kms': '1.8', - 'Quantity': '5', - 'notes': 'Gift items - fragile, handle gently', - 'expected_pickup_time': now - .add(const Duration(minutes: 20)) - .toIso8601String(), - 'riderslat': '11.0180', - 'riderslon': '76.9580', - }, - { - 'orderid': 'MOCK-Q-1005', - 'pickupid': 'del-mock-q-1005', - 'orderheaderid': '10005', - 'orderstatus': '', - 'pickupcustomer': 'Divya Menon', - 'pickupaddress': '120, West Street, Thudiyalur', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9889', - 'pickuplat': '11.0318', - 'pickuplong': '76.9965', - 'kms': '2.8', - 'Quantity': '15', - 'notes': 'Books and stationery - secure packaging', - 'expected_pickup_time': now - .add(const Duration(minutes: 60)) - .toIso8601String(), - 'riderslat': '11.0180', - 'riderslon': '76.9580', - }, - ]; -} - -List> getMockBookingPageItems() { - final now = DateTime.now(); - return [ - { - 'orderid': 'MOCK-B-3001', - 'pickupid': '3001', - 'orderheaderid': '103001', - 'orderstatus': 'accepted', - 'step': 1, - 'pickupcustomer': 'Lakshmi Narayanan', - 'pickupaddress': '67, East Gate, Uppilipalayam', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9500', - 'pickuplat': '11.0168', - 'pickuplong': '76.9558', - 'droplat': '11.0168', - 'droplon': '76.9558', - 'riderslat': '11.0180', - 'riderslon': '76.9580', - 'kms': '2.4', - 'Quantity': '6', - 'notes': 'Ready for pickup - ring the bell', - 'pickupcontactno': '9876543210', - 'expected_pickup_time': now - .add(const Duration(minutes: 12)) - .toIso8601String(), - }, - { - 'orderid': 'MOCK-B-3002', - 'pickupid': '3002', - 'orderheaderid': '103002', - 'orderstatus': 'pending', - 'step': 2, - 'pickupcustomer': 'Rajesh Bhat', - 'pickupaddress': '4, Sunshine Apartments, R.S. Puram', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9419', - 'pickuplat': '11.0347', - 'pickuplong': '76.9468', - 'droplat': '11.0347', - 'droplon': '76.9468', - 'riderslat': '11.0180', - 'riderslon': '76.9580', - 'kms': '3.1', - 'Quantity': '4', - 'notes': 'Parcel - handle with care', - 'pickupcontactno': '9876543211', - 'expected_pickup_time': now - .add(const Duration(minutes: 24)) - .toIso8601String(), - }, - { - 'orderid': 'MOCK-B-3003', - 'pickupid': '3003', - 'orderheaderid': '103003', - 'orderstatus': 'pending', - 'step': 3, - 'pickupcustomer': 'Neha Agarwal', - 'pickupaddress': '22, Lake View Road, Koundampalayam', - 'tenantname': 'Miler Logistics', - 'pickuplon': '77.0397', - 'pickuplat': '11.0254', - 'pickuplong': '77.0102', - 'droplat': '11.0254', - 'droplon': '77.0102', - 'riderslat': '11.0180', - 'riderslon': '76.9580', - 'kms': '4.6', - 'Quantity': '2', - 'notes': 'Urgent pickup - handle with care', - 'pickupcontactno': '9876543212', - 'expected_pickup_time': now - .add(const Duration(minutes: 38)) - .toIso8601String(), - }, - { - 'orderid': 'MOCK-B-3004', - 'pickupid': '3004', - 'orderheaderid': '103004', - 'orderstatus': 'skipped', - 'step': 4, - 'pickupcustomer': 'Anjali Nair', - 'pickupaddress': '10, New Colony, Saravanampatti', - 'tenantname': 'Miler Logistics', - 'pickuplon': '76.9600', - 'pickuplat': '11.0198', - 'pickuplong': '76.9512', - 'droplat': '11.0198', - 'droplon': '76.9512', - 'riderslat': '11.0180', - 'riderslon': '76.9580', - 'kms': '1.8', - 'Quantity': '5', - 'notes': 'Previously skipped - resume when ready', - 'pickupcontactno': '9876543213', - 'expected_pickup_time': now - .add(const Duration(minutes: 45)) - .toIso8601String(), - }, - ]; -} - -List> getMockPickups() { - final now = DateTime.now(); - return [ - { - 'orderid': 'MOCK-D-2001', - 'pickupid': 'del-mock-d-2001', - 'orderstatus': 'picked', - 'step': 1, - 'pickupcustomer': 'Sarah Johnson', - 'pickupaddress': '42 Maple Street, Apt 3B, New York, NY 10001', - 'tenantname': 'Miler Logistics', - 'pickuplong': -74.0060, - 'pickuplat': 40.7580, - 'pickuplon': -73.9855, - 'kms': '3.2', - 'notes': 'Ring doorbell, leave at door', - 'expected_pickup_time': now - .add(const Duration(minutes: 25)) - .toIso8601String(), - }, - { - 'orderid': 'MOCK-D-2002', - 'pickupid': 'del-mock-d-2002', - 'orderstatus': 'picked', - 'step': 2, - 'pickupcustomer': 'Michael Chen', - 'pickupaddress': '158 Broadway, Floor 5, New York, NY 10013', - 'tenantname': 'Miler Logistics', - 'pickuplong': -74.0020, - 'pickuplat': 40.7580, - 'pickuplon': -73.9855, - 'kms': '1.8', - 'notes': 'Call upon arrival, building requires access code #4521', - 'expected_pickup_time': now - .add(const Duration(minutes: 40)) - .toIso8601String(), - }, - { - 'orderid': 'MOCK-D-2003', - 'pickupid': 'del-mock-d-2003', - 'orderstatus': 'picked', - 'step': 3, - 'pickupcustomer': 'Emily Rodriguez', - 'pickupaddress': '275 7th Avenue, Suite 1200, New York, NY 10011', - 'tenantname': 'Miler Logistics', - 'pickuplong': -73.9950, - 'pickuplat': 40.7580, - 'pickuplon': -73.9855, - 'kms': '2.5', - 'notes': 'Pickup to reception desk', - 'expected_pickup_time': now - .add(const Duration(minutes: 55)) - .toIso8601String(), - }, - { - 'orderid': 'MOCK-D-2004', - 'pickupid': 'del-mock-d-2004', - 'orderstatus': 'picked', - 'step': 4, - 'pickupcustomer': 'James Wilson', - 'pickupaddress': '88 University Place, Ground Floor, New York, NY 10003', - 'tenantname': 'Miler Logistics', - 'pickuplong': -73.9920, - 'pickuplat': 40.7580, - 'pickuplon': -73.9855, - 'kms': '1.2', - 'notes': '', - 'expected_pickup_time': now - .add(const Duration(minutes: 70)) - .toIso8601String(), - }, - { - 'orderid': 'MOCK-D-2005', - 'pickupid': 'del-mock-d-2005', - 'orderstatus': 'skipped', - 'step': 5, - 'pickupcustomer': 'Anna Kim', - 'pickupaddress': '301 W 53rd Street, Apt 12A, New York, NY 10019', - 'tenantname': 'Miler Logistics', - 'pickuplong': -73.9850, - 'pickuplat': 40.7580, - 'pickuplon': -73.9855, - 'kms': '4.1', - 'notes': 'Cash payment expected', - 'expected_pickup_time': now - .add(const Duration(minutes: 90)) - .toIso8601String(), - }, - ]; -} - -// ───────────────────────────────────────────────────────────────────────────── -// DEMO TRIPS — three slot windows, every stop type, every decision state. -// -// ── This is a TEST FIXTURE. It does not ship. ── -// -// It used to live in `lib/data/mock_bookings.dart` and fill Home, Bookings and -// the handoff screen whenever the live backend answered with an empty list — -// gated on `kDebugMode`, which sounds safe and is not: the person running the -// debug build is the person deciding whether the feature works, and a screen -// full of convincing invented stops is exactly how a dead endpoint gets signed -// off. The app now shows what the hub actually said, including nothing. -// -// What survives is its second, legitimate job: a rich, deterministic route to -// test the pure `Trip` grouping logic against. Three trips covering every stop -// type in every state is a better fixture than anything a test would build by -// hand, so it moved here rather than being deleted. -// ───────────────────────────────────────────────────────────────────────────── - -/// Three trips covering every stop type in every state the rider can see. -/// -/// Trip 1 (09:00–12:00) — part-worked. A completed pickup AND a completed -/// delivery, a combined stop in progress, a skipped -/// delivery, and two still awaiting a decision. This -/// is the tab to look at for the locked rows, the -/// amber skip state and the completion ring. -/// Trip 2 (12:00–15:00) — delivery-heavy, all undecided. Multi-parcel drops, -/// a prepaid run, and two combined stops. -/// Trip 3 (15:00–18:00) — combined-heavy, all undecided, and the longest, -/// so select-all and multi-select are worth using. -/// -/// **Different data per miler.** This used to return one hard-coded list, so -/// every account — Ramesh, the next tester, the demo rider — opened Home to the -/// exact same six customers, and the same `DM-21xx` order ids. Identical ids -/// across accounts is the worse half: accept/reject is persisted locally by -/// order id, so decisions made while signed in as one miler came back as -/// already-accepted stops for the next one. -/// -/// So the *skeleton* is fixed and the *identity* is per rider. Which trip a stop -/// is in, its step, type, status and time window never change — that is what -/// guarantees every UI branch still has something to render, for every miler. -/// The customer, address, coordinates, parcel counts, cash and order ids are -/// drawn from [riderId] / [riderName], deterministically: the same miler gets -/// the same route every launch, two milers never get the same one. -/// -/// Called with no rider (signed out) it still yields a valid route — seed 0. -List> getDemoTripStops({String? riderName, int? riderId}) { - final now = DateTime.now(); - DateTime at(int hour, int minute) => - DateTime(now.year, now.month, now.day, hour, minute); - - final seed = _demoSeed(riderName, riderId); - final rng = _DemoRandom(seed); - - // Three digits stamped into every order id, so one miler's demo stops can - // never be mistaken — by the accepted/rejected store or by a human reading - // logs — for another's. - final tag = 100 + (seed % 900); - - // Where this miler's route runs. Stepping through the shared pool by a stride - // coprime with its length visits 18 distinct places, and a rider-specific - // start + stride means two milers overlap in few of them. - final start = seed % _demoPlaces.length; - final stride = const [1, 5, 7, 11, 13][seed % 5]; - - final stops = >[]; - for (var i = 0; i < _demoSkeleton.length; i++) { - final slot = _demoSkeleton[i]; - final place = _demoPlaces[(start + i * stride) % _demoPlaces.length]; - - // Loads vary per miler, but stay in the range each stop type implies: a - // delivery drops what it carries, a pickup collects, a combined does both. - final int? deliveryQty = slot.type == 'pickup' - ? null - : rng.between(1, slot.type == 'delivery' ? 4 : 3); - final int? pickupQty = slot.type == 'delivery' - ? null - : rng.between(1, slot.type == 'pickup' ? 8 : 5); - - stops.add( - _demoStop( - id: 'DM-$tag${slot.code}', - step: slot.step, - type: slot.type, - status: slot.status, - customer: place.customer, - address: place.address, - lat: place.lat, - lng: place.lng, - qty: pickupQty ?? deliveryQty ?? 1, - deliveryQty: deliveryQty, - pickupQty: pickupQty, - // Round sums to the nearest ten — a COD figure of ₹1247 reads as a bug. - cash: slot.cod ? (rng.between(15, 140) * 10).toDouble() : null, - notes: slot.notes, - due: at(slot.hour, slot.minute), - ), - ); - } - return stops; -} - -/// The Bookings tab's demo route — the same stops Home hands out, in the states -/// a rider who has *already accepted them* would see. -/// -/// Bookings was empty on the live backend for two separate reasons, and fixing -/// only one would have left it empty anyway: -/// -/// 1. Its mock fallback was gated on `!ApiConfig.useNewApi`, and the new API -/// is the default — the same gate Home and Earnings had. -/// 2. Even with stops injected, the tab drops anything still pending: it -/// shows work the rider has *taken on*, and `getDemoTripStops` returns -/// mostly `status: 'new'`, which that filter removes. So demo stops have -/// to arrive already accepted, or they vanish silently. -/// -/// This takes the miler's own route and rewrites the first twelve stops into a -/// believable working day, so the tab has something in every state it can -/// render: a couple finished (green rail), one active (the card that blocks the -/// others), one skipped (amber, awaiting a return), and the rest queued. -List> getDemoBookings({String? riderName, int? riderId}) { - final stops = getDemoTripStops(riderName: riderName, riderId: riderId); - if (stops.isEmpty) return stops; - - // Trips 1 and 2 — twelve stops is a full screen's worth of scrolling without - // pretending a rider has a forty-stop day. - final slice = stops.take(12).toList(); - - // Positions chosen so the progress bar shows every colour it has, and the - // active stop is early enough that queued stops sit visibly below it. - const statuses = { - 0: 'picked', // done - 1: 'delivered', // done - 2: 'active', // the one he is on - 3: 'skipped', // come back to it - }; - - return [ - for (var i = 0; i < slice.length; i++) - { - ...slice[i], - // Everything else is accepted: taken on, not yet started. That is the - // state the Bookings filter is looking for. - 'orderstatus': statuses[i] ?? 'accepted', - }, - ]; -} - -/// Stable per-miler seed. FNV-1a over the identity, so neighbouring rider ids -/// (9998, 9999) land far apart instead of drawing near-identical routes. -int _demoSeed(String? riderName, int? riderId) { - final key = (riderId != null && riderId > 0) - ? 'id:$riderId' - : 'name:${(riderName ?? '').trim().toLowerCase()}'; - if (key == 'name:') return 0; - var h = 2166136261; - for (final c in key.codeUnits) { - h ^= c; - h = (h * 16777619) & 0xFFFFFFFF; - } - return h & 0x7FFFFFFF; -} - -/// Deterministic per-miler number source. Not `dart:math`'s Random — this must -/// give the same route on every launch for the same rider, and be seedable from -/// an id rather than the clock. -class _DemoRandom { - int _state; - _DemoRandom(int seed) : _state = (seed | 1) & 0x7FFFFFFF; - - int between(int min, int max) { - _state = (_state * 1103515245 + 12345) & 0x7FFFFFFF; - return min + ((_state >> 8) % (max - min + 1)); - } -} - -/// A place a demo stop can happen at. The pool is shared; which slice of it a -/// miler gets is decided by their seed. -class _DemoPlace { - final String customer; - final String address; - final double lat; - final double lng; - const _DemoPlace(this.customer, this.address, this.lat, this.lng); -} - -/// 36 addresses across Coimbatore — twice what one miler's day uses, so two -/// milers' routes look properly different rather than reshuffled. -const List<_DemoPlace> _demoPlaces = [ - _DemoPlace( - 'Priya Sharma', - '45, Lake View Apartments, Race Course, Coimbatore', - 11.0243, - 76.9558, - ), - _DemoPlace( - 'Arun Venkatesh', - '23, Teachers Colony, Saibaba Colony, Coimbatore', - 11.0312, - 76.9678, - ), - _DemoPlace( - 'Meena Krishnan', - '5/2, North Avenue, Vadavalli, Coimbatore', - 11.0358, - 76.9902, - ), - _DemoPlace( - 'Ravi Subramanian', - '12A, Puliakulam Road, Coimbatore', - 11.0021, - 76.9721, - ), - _DemoPlace( - 'Sri Balaji Stores', - '19, Thadagam Road, Venkitapuram, Coimbatore', - 11.0289, - 76.9312, - ), - _DemoPlace( - 'Sundar Textiles', - '78, Cross Cut Road, Gandhipuram, Coimbatore', - 11.0168, - 76.9558, - ), - _DemoPlace( - 'Kavitha Ramesh', - '12, Bharathi Park Road, Coimbatore', - 11.0089, - 76.9712, - ), - _DemoPlace( - 'Hari Prasad', - '7, DB Road, RS Puram, Coimbatore', - 11.0043, - 76.9498, - ), - _DemoPlace( - 'Lakshmi Stores', - '61, Trichy Road, Ramanathapuram, Coimbatore', - 10.9931, - 76.9887, - ), - _DemoPlace( - 'Anand Electronics', - '3, Oppanakara Street, Town Hall, Coimbatore', - 10.9975, - 76.9601, - ), - _DemoPlace( - 'Nandhini Bakery', - '54, 100 Feet Road, Gandhipuram, Coimbatore', - 11.0198, - 76.9673, - ), - _DemoPlace( - 'Divya Fashions', - '9, Nava India Road, Peelamedu, Coimbatore', - 11.0271, - 77.0021, - ), - _DemoPlace( - 'Ganesh Traders', - '134, Avinashi Road, Hope College, Coimbatore', - 11.0301, - 77.0142, - ), - _DemoPlace( - 'Rekha Nair', - '27, Kalapatti Main Road, Coimbatore', - 11.0521, - 77.0312, - ), - _DemoPlace( - 'Bright Pharmacy', - '15, Mettupalayam Road, Thudiyalur, Coimbatore', - 11.0712, - 76.9401, - ), - _DemoPlace( - 'Selvam Hardware', - '88, Sathy Road, Ganapathy, Coimbatore', - 11.0432, - 76.9821, - ), - _DemoPlace( - 'Vetri Apartments', - '4B, Saravanampatti, Coimbatore', - 11.0798, - 77.0089, - ), - _DemoPlace( - 'Karthik Rajan', - '31, Vilankurichi Road, Coimbatore', - 11.0644, - 77.0201, - ), - _DemoPlace( - 'Muthu Silks', - '22, Raja Street, Town Hall, Coimbatore', - 10.9968, - 76.9585, - ), - _DemoPlace( - 'Aishwarya Gopal', - '8, Kamaraj Road, Sowripalayam, Coimbatore', - 10.9924, - 76.9974, - ), - _DemoPlace( - 'Velan Provisions', - '41, Ukkadam Main Road, Coimbatore', - 10.9861, - 76.9563, - ), - _DemoPlace( - 'Deepa Iyer', - '16, TV Samy Road, RS Puram, Coimbatore', - 11.0071, - 76.9531, - ), - _DemoPlace( - 'Kongu Steel Mart', - '102, Mettupalayam Road, Kavundampalayam, Coimbatore', - 11.0489, - 76.9382, - ), - _DemoPlace( - 'Sathya Agencies', - '7, Perur Main Road, Kuniamuthur, Coimbatore', - 10.9639, - 76.9302, - ), - _DemoPlace( - 'Ramya Sundaram', - '63, Perur Main Road, Kuniamuthur, Marudhamalai Road, Vadavalli, Coimbatore - 634783 ', - 11.0301, - 76.9151, - ), - _DemoPlace( - 'Green Leaf Grocers', - '29, Kovaipudur Main Road, Coimbatore', - 10.9291, - 76.9128, - ), - _DemoPlace( - 'Prakash Auto Parts', - '55, Sungam Bypass Road, Coimbatore', - 10.9832, - 76.9752, - ), - _DemoPlace( - 'Janaki Ammal', - '3, Vivekananda Road, Ram Nagar, Coimbatore', - 11.0092, - 76.9663, - ), - _DemoPlace( - 'Coimbatore Book House', - '19, Bashyakarlu Road, RS Puram, Coimbatore', - 11.0038, - 76.9457, - ), - _DemoPlace( - 'Tharun Sports', - '74, Tatabad, Gandhipuram, Coimbatore', - 11.0212, - 76.9631, - ), - _DemoPlace( - 'Malathi Nursing Home', - '12, Trichy Road, Singanallur, Coimbatore', - 10.9948, - 77.0298, - ), - _DemoPlace( - 'Sowmya Devi', - '47, Kurichi Main Road, Coimbatore', - 10.9418, - 76.9601, - ), - _DemoPlace( - 'Ashok Timbers', - '91, Palakkad Main Road, Madukkarai, Coimbatore', - 10.9061, - 76.9327, - ), - _DemoPlace( - 'Nithya Sweets', - '5, Cheran Managar, Kalapatti, Coimbatore', - 11.0562, - 77.0221, - ), - _DemoPlace( - 'Vasanth Motors', - '66, Karumathampatti Road, Neelambur, Coimbatore', - 11.0402, - 77.1012, - ), - _DemoPlace( - 'Elango Krishnamoorthy', - '18, Chinnavedampatti Road, Coimbatore', - 11.0601, - 76.9902, - ), -]; - -/// One position on the day. Everything here is the same for every miler — this -/// is the state-coverage contract the demo data exists to provide. -class _DemoSlot { - /// Trailing digits of the order id: trip number + step. Kept stable so a - /// given miler's stop 3 of trip 2 is always `…2203`. - final String code; - final int step; - - /// pickup | delivery | combined - final String type; - - /// new | picked | delivered | arrived | skipped - final String status; - final int hour; - final int minute; - - /// Whether this stop carries cash. The amount varies per miler; whether the - /// COD branch renders at all does not. - final bool cod; - final String? notes; - - const _DemoSlot( - this.code, - this.step, - this.type, - this.hour, - this.minute, { - this.status = 'new', - this.cod = false, - this.notes, - }); -} - -const List<_DemoSlot> _demoSkeleton = [ - // ── Trip 1 · 09:00 – 12:00 · part-worked ──────────────────────────────── - _DemoSlot('2101', 1, 'pickup', 9, 20, status: 'picked'), // done - // A finished DELIVERY, not just a finished pickup. - _DemoSlot('2102', 2, 'delivery', 9, 55, status: 'delivered'), - // Active → the locked "In progress" row. - _DemoSlot('2103', 3, 'combined', 10, 30, status: 'arrived', cod: true), - _DemoSlot( - '2104', - 4, - 'delivery', - 10, - 50, - status: 'skipped', - notes: 'Customer asked to come back after 11am', - ), - _DemoSlot('2105', 5, 'combined', 11, 15), - _DemoSlot( - '2106', - 6, - 'pickup', - 11, - 40, - notes: 'Bulk order — ask for the back entrance', - ), - - // ── Trip 2 · 12:00 – 15:00 · delivery-heavy, all undecided ────────────── - _DemoSlot('2201', 1, 'delivery', 12, 15), - _DemoSlot('2202', 2, 'delivery', 12, 40, notes: 'Buzz flat 3B'), - _DemoSlot('2203', 3, 'combined', 13, 5), - _DemoSlot('2204', 4, 'pickup', 13, 35, cod: true), - // Cash on the pickup leg of a combined stop. - _DemoSlot('2205', 5, 'combined', 14, 0, cod: true), - _DemoSlot('2206', 6, 'delivery', 14, 35), - - // ── Trip 3 · 15:00 – 18:00 · combined-heavy, all undecided ────────────── - _DemoSlot('2301', 1, 'combined', 15, 20), - _DemoSlot('2302', 2, 'delivery', 15, 50), - _DemoSlot( - '2303', - 3, - 'combined', - 16, - 15, - cod: true, - notes: 'Cold chain box — do not leave in the sun', - ), - _DemoSlot( - '2304', - 4, - 'pickup', - 16, - 45, - cod: true, - notes: 'Heavy items — bring the large box', - ), - _DemoSlot('2305', 5, 'combined', 17, 15), - _DemoSlot('2306', 6, 'delivery', 17, 45), -]; - -/// Builds one demo stop in the legacy stop shape the UI reads. -Map _demoStop({ - required String id, - required int step, - required String type, - required String customer, - required String address, - required double lat, - required double lng, - required DateTime due, - String status = 'new', - int qty = 1, - int? deliveryQty, - int? pickupQty, - double? cash, - String? notes, -}) { - return { - 'orderid': id, - 'pickupid': id.replaceAll('DM-', ''), - 'orderheaderid': id.replaceAll('DM-', ''), - 'pickuplocationid': '1', - 'orderstatus': status, - 'step': step, - 'type': type, - 'pickupcustomer': customer, - 'tenantname': 'Doormile', - 'pickupaddress': address, - 'pickupcontactno': '9876543210', - 'pickuplat': '$lat', - 'pickuplon': '$lng', - 'pickuplong': '$lng', - 'Quantity': '$qty', - if (deliveryQty != null) 'deliveryqty': '$deliveryQty', - if (pickupQty != null) 'pickupqty': '$pickupQty', - if (cash != null) 'collectionamt': '$cash', - if (notes != null) 'notes': notes, - 'weight': '${(qty * 1.4).toStringAsFixed(1)}', - 'expected_pickup_time': due.toIso8601String(), - 'otp': type == 'pickup' ? '' : '4821', - }; -} diff --git a/test/geofence_flag_test.dart b/test/geofence_flag_test.dart new file mode 100644 index 0000000..56d3413 --- /dev/null +++ b/test/geofence_flag_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/controllers/pickups_controller.dart'; + +/// Whichever way the proximity fence is set, it must be set **once** and on +/// purpose. +/// +/// [kGeofenceEnforced] decides whether "Picked up" means the rider was standing +/// at the door or merely pressed a button, and it has already been wrong in +/// both directions: it was `kDebugMode` — so every debug build had no fence and +/// the fence was therefore never tested — and then a hard `false`, so the flow +/// could not be walked at a desk at all. +/// +/// **It is currently OFF**, decided on 2026-08-19 because the fence was +/// refusing real work: riders pressing *Picked up* at a counter were told they +/// were kilometres away, most often because the booking's own coordinates were +/// wrong rather than the rider. See the declaration for the full reasoning and +/// for how to restore it. +/// +/// So this file no longer guards a direction. It guards the two properties that +/// made the old defaults dangerous: that the state is *deliberate*, and that +/// every gate reads the same switch. +void main() { + test('there is one switch, and the legacy name derives from it', () { + // Two constants that could disagree is exactly how a build ends up with + // the fence on in one gate and off in the other — the bulk check on Home + // and the per-stop check in the controller are separate code paths that + // must never answer differently. + expect(kBypassGeofenceForTesting, !kGeofenceEnforced); + }); + + test('enforcement is off, and that is the recorded decision', () { + // If this fails, one of two things happened, and both should be read rather + // than silenced: + // + // • the default was changed back to enforced — which is the intended + // end state, so update this test with the date and the reason; or + // • this run passed `--dart-define=ENFORCE_GEOFENCE=true`, in which case + // the failure is correct and is telling you this build has the fence on. + expect( + kGeofenceEnforced, + isFalse, + reason: + 'Proximity enforcement is expected to be OFF (see kGeofenceEnforced). ' + 'Either the default was restored, or this run passed ' + '--dart-define=ENFORCE_GEOFENCE=true.', + ); + }); +} diff --git a/test/home_appbar_test.dart b/test/home_appbar_test.dart index 73dddc0..a2b2b02 100644 --- a/test/home_appbar_test.dart +++ b/test/home_appbar_test.dart @@ -62,7 +62,11 @@ void main() { child: Container(key: barKey, height: 96, color: Colors.white), ), SliverToBoxAdapter( - child: Container(key: contentKey, height: 2000, color: Colors.grey), + child: Container( + key: contentKey, + height: 2000, + color: Colors.grey, + ), ), ], ), @@ -80,8 +84,11 @@ void main() { // Not merely pushed off-screen — a sliver that leaves the viewport and its // cache extent is unmounted outright, so the duty toggle stopped existing // rather than just moving. This is the behaviour being fixed. - expect(find.byKey(barKey), findsNothing, - reason: 'this is the behaviour that hid the duty toggle'); + expect( + find.byKey(barKey), + findsNothing, + reason: 'this is the behaviour that hid the duty toggle', + ); }); testWidgets('as a Column child, the bar does not move', (tester) async { @@ -92,15 +99,21 @@ void main() { await tester.dragFrom(inScrollArea, const Offset(0, -300)); await tester.pumpAndSettle(); - expect(tester.getRect(find.byKey(barKey)), before, - reason: 'the app bar is fixed'); + expect( + tester.getRect(find.byKey(barKey)), + before, + reason: 'the app bar is fixed', + ); // And the page really did scroll underneath it. - expect(tester.getRect(find.byKey(contentKey)).top, - lessThan(contentBefore - 200)); + expect( + tester.getRect(find.byKey(contentKey)).top, + lessThan(contentBefore - 200), + ); }); - testWidgets('the bar stays put through a long scroll and back', - (tester) async { + testWidgets('the bar stays put through a long scroll and back', ( + tester, + ) async { await tester.pumpWidget(pinned()); final rect = tester.getRect(find.byKey(barKey)); @@ -111,13 +124,18 @@ void main() { } }); - testWidgets('pull to refresh still fires below the fixed bar', - (tester) async { + testWidgets('pull to refresh still fires below the fixed bar', ( + tester, + ) async { var fired = 0; - await tester.pumpWidget(pinned(onRefresh: () async { - fired++; - await Future.delayed(const Duration(milliseconds: 50)); - })); + await tester.pumpWidget( + pinned( + onRefresh: () async { + fired++; + await Future.delayed(const Duration(milliseconds: 50)); + }, + ), + ); final rect = tester.getRect(find.byKey(barKey)); await tester.flingFrom(inScrollArea, const Offset(0, 320), 1200); @@ -131,11 +149,16 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('the refresh spinner comes down below the bar, not behind it', - (tester) async { - await tester.pumpWidget(pinned(onRefresh: () async { - await Future.delayed(const Duration(milliseconds: 400)); - })); + testWidgets('the refresh spinner comes down below the bar, not behind it', ( + tester, + ) async { + await tester.pumpWidget( + pinned( + onRefresh: () async { + await Future.delayed(const Duration(milliseconds: 400)); + }, + ), + ); await tester.flingFrom(inScrollArea, const Offset(0, 320), 1200); await tester.pump(); @@ -145,8 +168,10 @@ void main() { expect(spinner, findsOneWidget); // `edgeOffset` is no longer needed precisely because the scroll viewport now // starts under the bar — so the indicator is already in clear space. - expect(tester.getRect(spinner).top, - greaterThanOrEqualTo(tester.getRect(find.byKey(barKey)).bottom - 0.01)); + expect( + tester.getRect(spinner).top, + greaterThanOrEqualTo(tester.getRect(find.byKey(barKey)).bottom - 0.01), + ); await tester.pumpAndSettle(); }); } diff --git a/test/home_empty_state_test.dart b/test/home_empty_state_test.dart new file mode 100644 index 0000000..6186eda --- /dev/null +++ b/test/home_empty_state_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lottie/lottie.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A DAY WITH NO WORK HAS TO SAY SO +/// +/// Home's empty branch rendered `SizedBox.shrink()` — literally nothing — so a +/// rider whose hub had not assigned him anything opened the app to a blank +/// screen and could not tell it apart from one that had failed to load. Two +/// very different situations, one identical rendering: wait, or report it. +/// +/// The empty state itself was never missing; it was orphaned when the trip tabs +/// were removed and nothing called it any more. This pins the wiring, because +/// the failure is silent — everything compiles and the screen just goes quiet. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Future pump(WidgetTester tester, {required bool anyStops}) async { + tester.view.physicalSize = const Size(1170, 2532); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: Trip.fromStops( + id: 't', + stops: anyStops + ? [ + { + 'orderid': 'A', + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '12 Main Rd, Coimbatore', + 'orderstatus': 'new', + 'Quantity': 1, + }, + ] + : const [], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('a trip with no stops renders something, not nothing', ( + tester, + ) async { + await pump(tester, anyStops: false); + + // Whatever the composition, the screen must not be blank: a rider looking + // at an empty rectangle cannot tell "no work" from "load failed". + expect(find.byType(TripCard), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('a trip with work draws the route, not the empty state', ( + tester, + ) async { + await pump(tester, anyStops: true); + + expect(find.text('Sri Balaji Stores'), findsOneWidget); + expect( + find.byType(Lottie), + findsNothing, + reason: 'the empty animation must not sit under a populated route', + ); + }); +} diff --git a/test/home_grid_test.dart b/test/home_grid_test.dart new file mode 100644 index 0000000..c3b2d4a --- /dev/null +++ b/test/home_grid_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE GRID, MEASURED +/// +/// Home is read by running the eye down two columns: names on the left, bags +/// and figures on the right. Every point of drift in either costs a fixation, +/// and drift is invisible in code review — it comes from nested padding that +/// each looks correct where it is written. +/// +/// The select-all was 36pt off the name axis, because the row is railed by its +/// caller *and* reserved a second rail inside the widget: two corrections for +/// one indent. Then it was 2pt off, because the rail it sits in is 2 wider +/// than the order rows' own. Both were found by measuring, not by reading. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map meal(String id, String who, {int step = 1}) => { + 'orderid': id, + 'type': 'delivery', + 'step': step, + 'pickupcustomer': who, + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'sourcename': 'Vidhya Kitchen', + 'pickupcontactno': '9787698259', + 'pickupaddress': '4 Cross, RS Puram, Coimbatore', + 'dropaddress': '12 SNS Colony, Peelamedu, Coimbatore 641004', + 'Quantity': '1', + }; + + Future pumpHome( + WidgetTester tester, { + double width = 390, + double textScale = 1.0, + int orders = 3, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + // A real phone's geometry. A taller view with the same design size scales + // every `.h` by the ratio and makes every measurement here a fiction — + // which is exactly how a "116dp row" was once reported on a 62dp row. + tester.view.physicalSize = Size(width * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + final trip = Trip.fromStops( + id: 'run', + stops: [ + for (var i = 1; i <= orders; i++) meal('$i', 'Customer $i', step: i), + ], + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + backgroundColor: ColorConstants.daylightSurface, + body: SingleChildScrollView( + child: TripCard( + trip: trip, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onStopTap: (_) {}, + onCallStop: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('the two columns', () { + testWidgets('every customer name starts on one axis', (tester) async { + await pumpHome(tester, orders: 4); + final lefts = [ + for (var i = 1; i <= 4; i++) + tester.getRect(find.text('Customer $i')).left, + ]; + for (final l in lefts) { + expect(l, closeTo(lefts.first, 0.5)); + } + }); + + testWidgets('every Bag ends on one axis', (tester) async { + await pumpHome(tester, orders: 4); + final rights = [ + for (var i = 1; i <= 4; i++) tester.getRect(find.text('Bag $i')).right, + ]; + for (final r in rights) { + expect(r, closeTo(rights.first, 0.5)); + } + }); + + testWidgets('the select-all label sits on the name axis', (tester) async { + await pumpHome(tester); + + // The regression this file exists for. It heads the list it selects, so + // it starts where the list starts — not one rail further in, and not two + // points off it either. + final label = tester.getRect(find.text('Select all 3')).left; + final name = tester.getRect(find.text('Customer 1')).left; + expect( + label, + closeTo(name, 0.5), + reason: 'the select-all is $label, the names are $name', + ); + }); + + testWidgets('the kitchen and its state share one axis', (tester) async { + await pumpHome(tester); + expect( + tester.getRect(find.text('Pending')).left, + closeTo(tester.getRect(find.text('Vidhya Kitchen')).left, 0.5), + ); + }); + }); + + group('type ranks the page', () { + testWidgets('the kitchen outranks the customer, which outranks the area', ( + tester, + ) async { + await pumpHome(tester); + + final kitchen = tester.widget(find.text('Vidhya Kitchen')).style!; + final name = tester.widget(find.text('Customer 1')).style!; + final area = tester.widget(find.text('Peelamedu').first).style!; + + expect(kitchen.fontSize, 19); + expect(kitchen.fontWeight, FontWeight.w700); + expect(name.fontSize, 15); + expect(area.fontSize, 12.5); + + // A customer row must never overpower the heading it sits under. + expect(name.fontSize!, lessThan(kitchen.fontSize!)); + expect(area.fontSize!, lessThan(name.fontSize!)); + expect(area.fontSize!, greaterThanOrEqualTo(12)); + }); + }); + + group('rows grow, they do not clip', () { + for (final (width, scale) in const [ + (390.0, 1.0), + (390.0, 2.0), + (375.0, 1.0), + (375.0, 2.0), + (320.0, 1.0), + (320.0, 2.0), + ]) { + testWidgets('${width.toInt()}px @ ${scale}x lays out clean', ( + tester, + ) async { + await pumpHome(tester, width: width, textScale: scale, orders: 4); + expect( + tester.takeException(), + isNull, + reason: 'no overflow at ${width.toInt()}px / ${scale}x', + ); + }); + } + + testWidgets('a one-line row clears the touch floor and grows at 2x', ( + tester, + ) async { + await pumpHome(tester); + final at1x = tester.getRect(find.byType(PickupTimelineStep).first).height; + expect( + at1x, + greaterThanOrEqualTo(44), + reason: 'a step is reachable with a gloved thumb', + ); + + await pumpHome(tester, textScale: 2.0); + final at2x = tester.getRect(find.byType(PickupTimelineStep).first).height; + expect( + at2x, + greaterThan(at1x), + reason: 'density comes from removing furniture, never from clipping', + ); + }); + }); +} diff --git a/test/home_gutter_test.dart b/test/home_gutter_test.dart new file mode 100644 index 0000000..b4996f7 --- /dev/null +++ b/test/home_gutter_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_brief_strip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// ── Home stands on one column ── +/// +/// The trip brief and the route are separate widgets with separate paddings, +/// and they drifted: the brief padded 12 while the route padded 16, so the +/// two header rows the rider reads first — "N stops left" and "TODAY'S RUN" — +/// sat four points apart, directly above a list of stop cards that were all on +/// the wider one. Four points is the worst distance for two edges to be apart: +/// far enough to see, near enough to read as a mistake rather than a margin. +/// +/// A comment in the brief already asserted the two shared a column. It had +/// been wrong for as long as it had been there, which is the argument for +/// pinning it here instead: a number two files apart from the number it must +/// equal only stays equal if something checks. +void main() { + Map order(String id, String customer) => { + 'orderid': id, + 'tripid': 'T1', + 'type': 'delivery', + 'pickupcustomer': customer, + 'pickupaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'orderstatus': 'new', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'pickupcontactno': '9787698259', + 'Quantity': 1, + 'expected_pickup_time': DateTime.now() + .add(const Duration(minutes: 20)) + .toIso8601String(), + 'pickuplat': 11.0368, + 'pickuplon': 76.9758, + 'droplat': 11.0408, + 'droplon': 76.9798, + }; + + Future pump(WidgetTester tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userid': 1}); + tester.view.physicalSize = const Size(390 * 3, 1400 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + addTearDown(Get.reset); + + final trip = Trip.groupIntoTrips([ + order('K1-1', 'Joe Mathew'), + order('K1-2', 'Arun Prakash'), + ]).first; + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 1400), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + riderLat: 11.0168, + riderLng: 76.9558, + acceptedIds: const {}, + rejectedIds: const {}, + selectedIds: const {}, + collectedIds: const {}, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onStopTap: (_) {}, + onCallStop: (_) {}, + onNavigateStop: (_) {}, + onAdvanceStop: (_) {}, + onViewRoute: () {}, + onPreviewGroup: (_) {}, + onContinueStop: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + for (var i = 0; i < 25; i++) { + await tester.pump(const Duration(milliseconds: 80)); + } + } + + testWidgets('the trip brief and the route share one left edge', ( + tester, + ) async { + await pump(tester); + + // The brief's first ink is the hero numeral now — the caption sits to + // its right — so the claim is measured on the numeral. + final brief = tester.getRect( + find + .descendant(of: find.byType(TripBriefStrip), matching: find.text('2')) + .first, + ); + final route = tester.getRect(find.text('TODAY’S RUN')); + + expect( + brief.left, + route.left, + reason: + 'Home\'s two header rows must start on the same column. ' + 'brief=${brief.left} route=${route.left} — check the horizontal ' + 'padding in trip_brief_strip.dart against trip_card._timeline().', + ); + }); +} diff --git a/test/home_shots_test.dart b/test/home_shots_test.dart new file mode 100644 index 0000000..106c502 --- /dev/null +++ b/test/home_shots_test.dart @@ -0,0 +1,437 @@ +@Tags(['shots']) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/golden_fonts.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/offline/offline_banner.dart'; + +/// Throwaway visual harness for Home's route block. +/// +/// `_HomepageState` cannot be pumped — Get, SharedPreferences, Geolocator, live +/// fetches — but [TripCard] is a pure `StatelessWidget` over a [Trip] plus the +/// accepted / rejected / selected / collected id sets, which is exactly the +/// surface being redesigned. Run with `--update-goldens`. +void main() { + setUpAll(() async { + // ── The harness must load the family the app asks for ── + // + // `FontConstants.fontFamily` is what every style names, and a golden + // rendered without that family loaded is a picture of the fallback — which + // is how a font regression gets baked into a reference image and then + // asserted against forever. Poppins ships as separate weight files, so each + // one is registered under the same family with its own `FontWeight`; + // without that, `w800` headings render as smeared SemiBold in the goldens + // and nowhere else. + await loadGoldenFonts(); + }); + + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + final now = DateTime.now(); + + Map order( + String id, + String customer, + String kitchenId, + String kitchenName, { + String address = '12, SNS Colony, Peelamedu, Coimbatore 641004', + String status = 'new', + String? bagLabel, + int minsOut = 20, + double lat = 11.0168, + double lng = 76.9558, + }) => { + 'orderid': id, + 'tripid': 'T1', + 'type': 'delivery', + 'pickupcustomer': customer, + 'pickupaddress': address, + 'dropaddress': address, + 'orderstatus': status, + 'kitchenid': kitchenId, + 'kitchenname': kitchenName, + // The kitchen's number. Without it the card's Call control is correctly + // suppressed — a dead button on a card is worse than no button — so the + // fixture has to carry one for the shots to show the state at all. + 'pickupcontactno': '9787698259', + if (bagLabel != null) 'baglabel': bagLabel, + 'Quantity': 1, + 'expected_pickup_time': now + .add(Duration(minutes: minsOut)) + .toIso8601String(), + // ── `pickuplon`, not `pickuplng` ── + // + // The fixture used `pickuplng`, which no reader in the app looks for: + // `Trip._coordsOf` and `RouteMetricsHelper.metersToStop` both read + // `pickuplon` / `PickupLon`. So every stop in this harness was silently + // coordinate-less, every leg estimate came out zero, and the shots have + // been showing the no-distance degradation while claiming to show a route. + 'pickuplat': lat, + 'pickuplon': lng, + 'droplat': lat + 0.004, + 'droplon': lng + 0.004, + }; + + /// One kitchen's orders. [away] nudges the whole group off the rider's own + /// position so each kitchen sits at a different, plausible distance — which + /// is the thing the header's journey column exists to show. + List> kitchen( + String id, + String name, + List customers, { + String status = 'new', + int startAt = 1, + double away = 0.02, + }) => [ + for (var i = 0; i < customers.length; i++) + order( + '$id-${startAt + i}', + customers[i], + id, + name, + status: status, + minsOut: 15 + i * 4, + lat: 11.0168 + away, + lng: 76.9558 + away, + ), + ]; + + Future shoot( + WidgetTester tester, + String name, { + required List> stops, + Set accepted = const {}, + Set rejected = const {}, + Set selected = const {}, + Set collected = const {}, + ServiceProfile profile = ServiceProfile.milkMan, + double height = 1400, + double width = 390, + double textScale = 1.0, + bool offline = false, + Future Function(WidgetTester)? settle, + }) async { + ServiceProfile.setActive(profile); + SharedPreferences.setMockInitialValues({'userid': 1}); + + tester.view.physicalSize = Size(width * 3, height * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + final trip = Trip.groupIntoTrips(stops).first; + + await tester.pumpWidget( + // ── Phone-scale layout on a taller canvas ── + // + // ScreenUtil derives `.h` from the *view* height, so rendering a 1800pt + // surface against a 844pt design size multiplies every vertical token by + // 2.13 and the screenshot lies about the spacing. Overriding MediaQuery + // to the real phone keeps the scale honest; the surface stays tall so the + // whole page fits in one frame. + // ── Phone-scale layout on a taller canvas ── + // + // ScreenUtil derives `.w`/`.h` from the *view*, so a 1800pt surface + // against a 844pt design size multiplies every vertical token by 2.13 and + // the screenshot lies about the spacing. Declaring the design size as the + // surface makes both factors exactly 1 — which is what a real 390×844 + // phone produces against the app's own design size. + ScreenUtilInit( + designSize: Size(width, height), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: Scaffold( + // The ground `MilerSheet` actually paints on Home — the + // **canvas**, not white. This said working, and it was wrong: + // `homepage.dart` builds `MilerSheet(color: _surfaceColor)` with + // `_surfaceColor = daylightSurface`. So every shot in here was + // taken on a white page while the device drew a grey one, and + // the flatness the screen actually had could not appear in its + // own reference images. See [MilerPanel]. + backgroundColor: MilerSurface.canvas, + body: OfflineScope( + offline: offline, + child: SingleChildScrollView( + child: TripCard( + trip: trip, + // Where the rider is standing. Home passes the live + // geocoded fix; the shots pin one so the distance column + // renders the same numbers on every run. + riderLat: 11.0168, + riderLng: 76.9558, + acceptedIds: accepted, + rejectedIds: rejected, + selectedIds: selected, + collectedIds: collected, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onStopTap: (_) {}, + onCallStop: (_) {}, + onNavigateStop: (_) {}, + onAdvanceStop: (_) {}, + onViewRoute: () {}, + // Supplied so the card's "i" renders — without a preview + // callback the control is correctly suppressed, and the + // shots would show a state the app never has. + onPreviewGroup: (_) {}, + onContinueStop: (_) {}, + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + for (var i = 0; i < 25; i++) { + await tester.pump(const Duration(milliseconds: 80)); + } + // Drive the screen into the state being photographed — a tap, a scroll — + // after the first settle and before the shot. + if (settle != null) await settle(tester); + + // ── A build failure must not become a golden ── + // + // This was `while (tester.takeException() != null) {}` — every exception + // swallowed, silently. A negative `Container.margin` (which asserts, via + // `Padding`) therefore replaced a whole pickup group with a `RenderErrorBox` + // and the harness cheerfully wrote it to disk as the new reference image. + // A screenshot suite that cannot fail is not a test. + final error = tester.takeException(); + expect( + error, + isNull, + reason: 'shots/home_$name.png would have captured a build failure', + ); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('shots/home_$name.png'), + ); + } + + final vidhya = kitchen('K1', 'Vidhya Kitchen', [ + 'Joe Mathew', + 'Arun Prakash', + 'Ravi Kumar', + 'Priya Nair', + 'Kumar S', + ]); + final joe = kitchen( + 'K2', + 'Joe Kitchen', + ['Lakshmi R', 'Anitha Ramesh', 'Deepa Menon'], + startAt: 10, + away: 0.031, + ); + final third = kitchen( + 'K3', + 'Anna Kitchen', + ['Suresh B', 'Meena T', 'Vijay K', 'Nandini P'], + startAt: 20, + away: 0.047, + ); + + testWidgets('one kitchen, five orders', (t) async { + await shoot(t, 'one_kitchen', stops: vidhya, height: 1800); + }); + + // ── The brief opened ── + // + // It defaults closed, so every reference shot on this screen was taken with + // the day's figures hidden — and the figures are most of what a rider looks + // at when he opens Home, which is the state a device screenshot caught the + // moment anyone tapped it. A state with no reference image is a state + // nobody reviews. + testWidgets('the trip brief opened', (t) async { + await shoot( + t, + 'brief_open', + stops: [...vidhya, ...joe], + height: 2400, + settle: (tester) async { + await tester.tap(find.textContaining('stops left').first); + for (var i = 0; i < 25; i++) { + await tester.pump(const Duration(milliseconds: 80)); + } + }, + ); + }); + + testWidgets('two kitchens', (t) async { + await shoot(t, 'two_kitchens', stops: [...vidhya, ...joe], height: 2400); + }); + + testWidgets('three kitchens', (t) async { + await shoot( + t, + 'three_kitchens', + stops: [...vidhya, ...joe, ...third], + height: 3000, + ); + }); + + testWidgets('accepted', (t) async { + await shoot( + t, + 'accepted', + stops: [...vidhya, ...joe], + accepted: {for (final s in vidhya) s['orderid'] as String}, + height: 2400, + ); + }); + + testWidgets('selected', (t) async { + await shoot( + t, + 'selected', + stops: [...vidhya, ...joe], + accepted: {for (final s in vidhya) s['orderid'] as String}, + selected: {for (final s in vidhya.take(3)) s['orderid'] as String}, + height: 2400, + ); + }); + + testWidgets('arrived', (t) async { + await shoot( + t, + 'arrived', + stops: [ + ...kitchen('K1', 'Vidhya Kitchen', [ + 'Joe Mathew', + 'Arun Prakash', + 'Ravi Kumar', + 'Priya Nair', + 'Kumar S', + ], status: 'arrived'), + ...joe, + ], + accepted: {for (final s in vidhya) s['orderid'] as String}, + height: 2400, + ); + }); + + testWidgets('one group picked, another active', (t) async { + await shoot( + t, + 'group_done', + stops: [...vidhya, ...joe], + accepted: { + for (final s in [...vidhya, ...joe]) s['orderid'] as String, + }, + collected: {for (final s in vidhya) s['orderid'] as String}, + height: 2000, + ); + }); + + testWidgets('long names', (t) async { + await shoot( + t, + 'long_text', + stops: kitchen( + 'K9', + 'Sri Venkateswara Subramanian Cloud Kitchen & Catering Services', + [ + 'Venkataraman Subramanian Balasubramaniam', + 'Chandrasekaran Muthukrishnan', + 'Ravi', + ], + ), + height: 1600, + ); + }); + + testWidgets('offline', (t) async { + await shoot(t, 'offline', stops: vidhya, offline: true, height: 1800); + }); + + testWidgets('parcel line', (t) async { + await shoot( + t, + 'parcel', + stops: vidhya, + profile: ServiceProfile.parcel, + height: 1800, + ); + }); + + testWidgets('text scale 1.3x', (t) async { + await shoot( + t, + 'scale_1_3x', + stops: [...vidhya, ...joe], + textScale: 1.3, + height: 3000, + ); + }); + + // ── The states the redesign has to survive, not just the happy one ── + // + // Everything above was the shape the screen was designed against. These are + // the ones that break compositions: a full selection (where the bar owns the + // screen and the group's journey action must stand down), a picked-out run + // (nothing left to decide), and the two text-scale extremes. + + testWidgets('all selected', (t) async { + await shoot( + t, + 'all_selected', + stops: [...vidhya, ...joe], + accepted: {for (final s in vidhya) s['orderid'] as String}, + selected: {for (final s in vidhya) s['orderid'] as String}, + height: 2400, + ); + }); + + testWidgets('pending, nothing decided yet', (t) async { + await shoot(t, 'pending', stops: [...vidhya, ...joe], height: 2400); + }); + + testWidgets('the whole run is collected', (t) async { + await shoot( + t, + 'run_complete', + stops: [...vidhya, ...joe], + accepted: { + for (final s in [...vidhya, ...joe]) s['orderid'] as String, + }, + collected: { + for (final s in [...vidhya, ...joe]) s['orderid'] as String, + }, + height: 1400, + ); + }); + + testWidgets('text scale 2.0x on a 320pt phone', (t) async { + await shoot( + t, + 'scale_2x', + stops: vidhya, + accepted: {for (final s in vidhya) s['orderid'] as String}, + textScale: 2.0, + width: 320, + height: 3000, + ); + }); +} diff --git a/test/home_structure_test.dart b/test/home_structure_test.dart new file mode 100644 index 0000000..973fd9e --- /dev/null +++ b/test/home_structure_test.dart @@ -0,0 +1,410 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// CONTENT LEADS, CONTAINERS RECEDE +/// +/// Home's grouping unit is a **pickup place**, and for three iterations it was +/// drawn as a box. First a card per order — fifteen boxes on a route. Then a +/// card per place, which was the right correction to the wrong object and +/// still the wrong object: a route already says that two things belong +/// together, with a spine, a node and an indent, so the rectangle said it a +/// second time in a louder voice. The orders then needed a *tinted* rectangle +/// inside that one to separate themselves from their own container, which is +/// the point at which the furniture is arguing with itself. +/// +/// These pin the third answer: no surface at all. The group is the spine plus +/// whitespace plus type, the orders sit on Home's own ground, and the three +/// things a rider does to a kitchen have three separate targets. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map meal(String id, String who, {int step = 1}) => { + 'orderid': id, + 'type': 'delivery', + 'step': step, + 'pickupcustomer': who, + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'sourcename': 'Vidhya Kitchen', + 'pickupcontactno': '9787698259', + 'pickupaddress': '4 Cross, RS Puram, Coimbatore', + 'dropaddress': '12 SNS Colony, Peelamedu, Coimbatore 641004', + 'Quantity': '1', + }; + + Future pumpHome( + WidgetTester tester, { + Set accepted = const {}, + Set selected = const {}, + void Function(String)? onToggleSelect, + double width = 390, + double textScale = 1.0, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + tester.view.physicalSize = Size(width * 3, 2600 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + final trip = Trip.fromStops( + id: 'run', + stops: [ + meal('1', 'Joe Mathew', step: 1), + meal('2', 'Arun Prakash', step: 2), + meal('3', 'Ravi Kumar', step: 3), + ], + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + backgroundColor: ColorConstants.daylightSurface, + body: SingleChildScrollView( + child: TripCard( + trip: trip, + acceptedIds: accepted, + selectedIds: selected, + onToggleSelect: onToggleSelect ?? (_) {}, + onSelectAll: (_) {}, + onStopTap: (_) {}, + onCallStop: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// Makes sure the kitchen's orders are on screen. + /// + /// The run's first place opens itself — a rider arriving at work should see + /// the counter he is riding to, not a closed list — so this only taps when + /// it is actually shut. Tapping unconditionally would *collapse* it, which + /// is how a row assertion ends up looking like a broken row. + Future openKitchen(WidgetTester tester) async { + if (find.text('Joe Mathew').evaluate().isEmpty) { + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + } + } + + group('the kitchen is not a box', () { + testWidgets('no surface, border, shadow or radius groups a kitchen', ( + tester, + ) async { + await pumpHome(tester); + + // Every decoration inside the group, at any depth. A grouping rectangle + // is exactly what this screen may not draw — see the header note. + final decorations = tester + .widgetList( + find.descendant( + of: find.byType(PickupTimelineGroup), + matching: find.byType(Container), + ), + ) + .map((c) => c.decoration) + .whereType(); + + // Circles are exempt: the sequence node and the tick are round marks on + // the spine, which is the language doing the grouping rather than a + // rectangle competing with it. + final rectangles = decorations.where((d) => d.shape != BoxShape.circle); + for (final d in rectangles) { + expect( + d.boxShadow, + anyOf(isNull, isEmpty), + reason: 'a shadow lifts a group off the page; the spine groups it', + ); + expect( + d.border, + isNull, + reason: 'an outline around a group is the card by another name', + ); + } + + // ── What a fill may be here, since the route moved onto a panel ── + // + // The original rule was "no filled rectangle at all": the orders sat on + // a white page, so any panel behind them was a card by another name. + // The route now stands on a white [MilerPanel], which re-created the + // exact flatness that rule was written against — an open kitchen was + // white-on-white. Two fills are now legitimate, and only these two: + // + // • exactly one canvas-toned well behind the open orders — the + // ladder's single permitted inset, reading as the card opening + // down to the page; + // • small white tags (the bag labels) standing on that well. + // + // What stays banned is what the rule always meant: a white panel wide + // enough to be a row's or the group's own card. + final fillBoxes = find + .descendant( + of: find.byType(PickupTimelineGroup), + matching: find.byType(DecoratedBox), + ) + .evaluate() + .where((e) { + final d = (e.widget as DecoratedBox).decoration; + return d is BoxDecoration && + d.shape != BoxShape.circle && + (d.color == ColorConstants.pureSurface || + d.color == ColorConstants.daylightSurface); + }) + .toList(); + + final wells = fillBoxes + .where( + (e) => + ((e.widget as DecoratedBox).decoration as BoxDecoration) + .color == + ColorConstants.daylightSurface, + ) + .toList(); + expect( + wells.length, + lessThanOrEqualTo(1), + reason: 'one well behind the open orders, never a stack of them', + ); + + for (final e in fillBoxes) { + final d = (e.widget as DecoratedBox).decoration as BoxDecoration; + if (d.color == ColorConstants.pureSurface) { + final w = e.renderObject!.paintBounds.width; + expect( + w, + lessThan(120), + reason: + 'a white fill inside the group must be a small tag — at ' + '${w.toStringAsFixed(1)}pt wide it is a row panel, which is ' + 'the card by another name', + ); + } + } + }); + + testWidgets('an order row carries no tile of its own', (tester) async { + await pumpHome(tester, accepted: const {'1'}, selected: const {'1'}); + + // Selection and acceptance are carried by the node — shape and fill — + // and by the name's weight. Painting whole rows made a list of five + // steps read as five loose objects on open ground. + final steps = find.byType(PickupTimelineStep); + expect(steps, findsWidgets); + + final tinted = tester + .widgetList( + find.descendant( + of: steps, + matching: find.byType(AnimatedContainer), + ), + ) + .map((c) => c.decoration) + .whereType() + // The node is a filled disc when selected — that is the channel + // carrying the state, and it is deliberately not a rectangle. + .where((d) => d.shape != BoxShape.circle) + .where((d) => d.color != null && d.color != Colors.transparent); + expect(tinted, isEmpty, reason: 'no wash across a row'); + }); + }); + + group('three targets, three jobs', () { + testWidgets('the kitchen header only expands — it never selects', ( + tester, + ) async { + final selected = []; + await pumpHome(tester, onToggleSelect: selected.add); + + // The header is the identity block. Tapping it opens and closes the + // place, and that is the whole of its contract: a gesture that also + // ticked an order would mean the same object did two things depending + // on data the rider cannot see. + await tester.tap(find.text('Vidhya Kitchen')); + await tester.pumpAndSettle(); + expect(selected, isEmpty, reason: 'disclosure is not selection'); + }); + + testWidgets('an order row selects, and does not open anything', ( + tester, + ) async { + final selected = []; + final opened = []; + await pumpHome(tester, onToggleSelect: selected.add); + await openKitchen(tester); + + await tester.tap(find.text('Joe Mathew')); + await tester.pumpAndSettle(); + expect(selected, ['1']); + expect(opened, isEmpty); + }); + + testWidgets('the bag block opens details, and does not select', ( + tester, + ) async { + final selected = []; + await pumpHome(tester, onToggleSelect: selected.add); + await openKitchen(tester); + + // Siblings, never nested: two recognisers over the same pixels resolve + // to neither, which is how the chevron was once dead for a whole build. + await tester.tap(find.text('Bag 1')); + await tester.pumpAndSettle(); + expect( + selected, + isEmpty, + reason: 'the bag corner is the way in, not a second tick', + ); + }); + }); + + group('the metadata is metadata', () { + testWidgets('Pending is neutral, not a warning', (tester) async { + await pumpHome(tester); + + // Amber is this app's exception hue. On a fresh morning every kitchen is + // pending, so spending it here opens the whole screen in warning and + // leaves nothing to say with when a stop genuinely goes wrong. + final word = tester.widget(find.text('Pending')); + expect(word.style!.color, ColorConstants.secondaryText); + expect(word.style!.color, isNot(ColorConstants.warning)); + }); + + testWidgets('a partly accepted kitchen says so as a fraction', ( + tester, + ) async { + await pumpHome(tester, accepted: const {'1'}); + + // Two of five accepted must never render the same word as five of five. + expect(find.text('Accepted 1/3'), findsOneWidget); + expect(find.text('Accepted'), findsNothing); + }); + + testWidgets('the state wears no capsule', (tester) async { + await pumpHome(tester); + + // ── Only the boxes that hug the word ── + // + // This walked *every* `DecoratedBox` above the text and demanded none of + // them be filled, which held only while Home had no filled surfaces at + // all. The route now stands on a white `MilerPanel` — layer 1, the page + // having a ground is the point — and that panel is an ancestor of every + // word on the screen, so the test began failing on a surface it was + // never about. + // + // A capsule is a box drawn *around a word*: it is the width of the word + // plus its padding. A page surface is not. Filtering by width keeps the + // original claim — the state is set, not stamped — and makes it immune + // to whatever the page is standing on. + final word = tester.getRect(find.text('Pending')); + final chip = find.ancestor( + of: find.text('Pending'), + matching: find.byType(DecoratedBox), + ); + for (final e in chip.evaluate()) { + final box = (e.widget as DecoratedBox).decoration; + if (box is! BoxDecoration) continue; + final r = tester.getRect(find.byWidget(e.widget)); + if (r.width > word.width + 48) continue; // a surface, not a capsule + expect( + box.color, + anyOf(isNull, Colors.transparent), + reason: 'operational metadata is set, not stamped', + ); + } + }); + }); + + group('the order row', () { + testWidgets('shows the area, never a truncated full address', ( + tester, + ) async { + await pumpHome(tester); + await openKitchen(tester); + + // `Peelamedu`, not `12 SNS Colony, Peelamedu, Coimb…`. The full address + // is a detail-sheet fact; what the rider decides on here is the area. + expect(find.textContaining('641004'), findsNothing); + expect(find.textContaining('12 SNS Colony'), findsNothing); + expect(find.text('Peelamedu'), findsWidgets); + }); + + testWidgets('one order, one bag — and the column is stable', ( + tester, + ) async { + await pumpHome(tester); + await openKitchen(tester); + + for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3']) { + expect(find.text(bag), findsOneWidget); + } + + // Right-aligned on one axis, so the phone can be read against a shelf. + final rights = [ + for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3']) + tester.getRect(find.text(bag)).right, + ]; + for (final r in rights) { + expect(r, closeTo(rights.first, 0.5)); + } + }); + + testWidgets('the name outranks the bag, and the bag yields first', ( + tester, + ) async { + await pumpHome(tester, textScale: 2.0, width: 320); + await openKitchen(tester); + + // `Bag 1` is compared against a shelf, not read as prose, so it stops + // scaling before the customer's name does. Left uncapped it took half a + // 320pt row at 2× and the name — the one item that must yield last — + // was what ellipsised. + final bag = tester.getRect(find.text('Bag 1')); + expect( + bag.width, + lessThan(120), + reason: 'the bag column does not grow into the name', + ); + expect(tester.takeException(), isNull, reason: 'and nothing overflows'); + }); + }); + + group('the offer states its own number', () { + testWidgets('resting reads "Select all 3"', (tester) async { + await pumpHome(tester); + await openKitchen(tester); + + // The count is what makes the offer an offer. It used to be bare, on the + // argument that the card above said `3 orders` — and the card is gone. + expect(find.text('Select all 3'), findsOneWidget); + }); + + testWidgets('partial and full both state the fraction', (tester) async { + await pumpHome(tester, selected: const {'1'}); + await openKitchen(tester); + expect(find.text('1 of 3 selected'), findsOneWidget); + + await pumpHome(tester, selected: const {'1', '2', '3'}); + await openKitchen(tester); + expect(find.text('All 3 selected · Clear'), findsOneWidget); + }); + }); +} diff --git a/test/intro_screen_test.dart b/test/intro_screen_test.dart index b16ea5c..b6b9bc0 100644 --- a/test/intro_screen_test.dart +++ b/test/intro_screen_test.dart @@ -20,10 +20,8 @@ void main() { await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), - builder: (_, _) => MaterialApp( - theme: AppTheme.light, - home: const Introscreen(), - ), + builder: (_, _) => + MaterialApp(theme: AppTheme.light, home: const Introscreen()), ), ); await tester.pumpAndSettle(); @@ -35,8 +33,9 @@ void main() { Size(390, 844), Size(430, 932), ]) { - testWidgets('all three slides lay out at ${size.width.toInt()}pt', - (tester) async { + testWidgets('all three slides lay out at ${size.width.toInt()}pt', ( + tester, + ) async { await pump(tester, screen: size); expect(tester.takeException(), isNull); @@ -76,8 +75,9 @@ void main() { // The old copy pitched a gig marketplace — "pickups near you", accepted from a // feed. This product assigns routes from a hub. Onboarding that teaches the // wrong model is worse than none, so the wrong words are pinned out. - testWidgets('does not promise a marketplace the app does not have', - (tester) async { + testWidgets('does not promise a marketplace the app does not have', ( + tester, + ) async { await pump(tester, screen: const Size(390, 844)); for (final wrong in [ 'Find pickups near you', @@ -85,7 +85,11 @@ void main() { 'Earn more, every shift', 'Powered by AI', ]) { - expect(find.text(wrong), findsNothing, reason: '"$wrong" is not this app'); + expect( + find.text(wrong), + findsNothing, + reason: '"$wrong" is not this app', + ); } }); } diff --git a/test/kitchen_group_test.dart b/test/kitchen_group_test.dart new file mode 100644 index 0000000..ad378c7 --- /dev/null +++ b/test/kitchen_group_test.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE KITCHEN IS THE CARD; ITS ORDERS ARE THE CONTENTS +/// +/// Home's pickup model is one place per card, disclosed to reveal the orders +/// under it, ticked in batches and advanced with one gesture: +/// +/// ``` +/// Vidhya Kitchen · 4 bags · 2 to accept ˅ +/// ◉ Joe Gandhipuram Bag 1 +/// ◉ Priya RS Puram Bag 2 +/// ``` +/// +/// ── The regression this file exists for ── +/// +/// Two functions answered "what is this place called" and read different keys: +/// `MilkRun.sourceNameOf` took `sourcename` / `kitchenname`, `stopSourceName` +/// took those **and** the CamelCase `SourceName` / `KitchenName` the payload +/// sometimes carries. `trip_card.dart` uses both in one expression, so a +/// booking with only the capitalised key was: +/// +/// • **not flat** — `stopSourceName` found a counter, so the group became a +/// foldable place with its orders underneath; and +/// • **titled "pickup"** — `sourceNameOf` found none, so `navigationLabel` +/// fell through to its leg description. +/// +/// On a device that is a group headed by a backend word, which the rider taps +/// and which opens a detail sheet instead of the list. Fixed at the domain +/// layer: one reader, four keys, `stopSourceName` delegating to it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + /// An order as the queue hands it over. [kitchenKey] is the variable under + /// test: every spelling the payload uses must group and name identically. + Map order( + String id, + String who, + String drop, { + String kitchenKey = 'KitchenName', + String status = 'new', + }) => { + 'orderid': id, + 'pickupid': int.parse(id), + 'step': int.parse(id) - 1000, + kitchenKey: 'Vidhya Kitchen', + 'sourceid': 'K1', + 'pickupcustomer': who, + 'dropaddress': drop, + 'pickupaddress': '9 Kitchen Street, Coimbatore', + 'orderstatus': status, + 'type': 'delivery', + 'pickuplat': 11.01, + 'pickuplon': 76.95, + }; + + Future pump( + WidgetTester tester, + List> stops, { + Set accepted = const {}, + Set selected = const {}, + Set collected = const {}, + double width = 390, + double scale = 1.0, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + tester.view.physicalSize = Size(width * 3, 1400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + minTextAdapt: true, + builder: (_, _) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: Trip.fromStops(id: 'T1', stops: stops), + acceptedIds: accepted, + rejectedIds: const {}, + collectedIds: collected, + selectedIds: selected, + onToggleSelect: (_) {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('the place is named from the domain, not the screen', () { + for (final key in const [ + 'sourcename', + 'SourceName', + 'kitchenname', + 'KitchenName', + ]) { + testWidgets('$key names the card and groups its orders', (tester) async { + await pump(tester, [ + order('1001', 'Joe', 'Gandhipuram, Coimbatore', kitchenKey: key), + order('1002', 'Priya', 'RS Puram, Coimbatore', kitchenKey: key), + ]); + + expect(find.text('Vidhya Kitchen'), findsOneWidget); + // The leg description must never surface as a place's name. + expect(find.text('pickup'), findsNothing); + // Grouped, so the orders are disclosed under the header rather than the + // header being the stop. + expect(find.text('Joe'), findsOneWidget); + expect(find.text('Priya'), findsOneWidget); + }); + } + + testWidgets('a payload naming no counter still renders its one stop', ( + tester, + ) async { + final o = order('1001', 'Joe', 'Gandhipuram, Coimbatore'); + o.remove('KitchenName'); + o.remove('sourceid'); + await pump(tester, [o]); + + // Nothing to group under, so the header IS the stop — named for the + // customer, never for the leg. + expect(find.text('Joe'), findsWidgets); + expect(find.text('pickup'), findsNothing); + }); + }); + + group('the row carries what the rider decides on', () { + testWidgets('the drop area, not the booking reference', (tester) async { + await pump(tester, [order('1001', 'Joe', 'Gandhipuram, Coimbatore')]); + + // "Are Joe and Priya the same trip out of the door, or opposite ends of + // the city?" is the question this list answers. A reference answers the + // one the office asks, once, when something has gone wrong — it lives on + // the detail sheet and the Activity record. + expect(find.text('Gandhipuram'), findsOneWidget); + expect(find.textContaining('1001'), findsNothing); + expect(find.text('Bag 1'), findsOneWidget); + }); + + // Updated when `areaOf` was corrected against real Coimbatore addresses + // on a device: it used to take the first part with no digits in it, which + // is the *street*. The row's job is to say which neighbourhood a stop is + // in, so the rule now prefers the shortest digit-free part — the locality + // — and this expectation follows the rule rather than the other way round. + testWidgets('a door number is skipped for the area behind it', ( + tester, + ) async { + await pump(tester, [ + order('1001', 'Joe', '12, Cross Cut Road, Gandhipuram'), + ]); + expect(find.text('Gandhipuram'), findsOneWidget); + expect(find.textContaining('12,'), findsNothing); + }); + + testWidgets('no drop means no line, never a blank one', (tester) async { + final o = order('1001', 'Joe', ''); + o.remove('dropaddress'); + o.remove('pickupaddress'); + await pump(tester, [o]); + + expect(find.text('Joe'), findsWidgets); + expect(find.text(''), findsNothing); + }); + + testWidgets('one order is one bag, counted off the orders', (tester) async { + await pump(tester, [ + order('1001', 'Joe', 'Gandhipuram, Coimbatore'), + order('1002', 'Priya', 'RS Puram, Coimbatore'), + order('1003', 'Arun', 'Peelamedu, Coimbatore'), + ]); + // The header states the load once, in the unit the rider carries; the + // check that orders and bags agree is the manifest itself, one named + // bag per order. `N orders · N bags` was the same number twice and it + // cost the header its `to accept` clause on a real route. + expect(find.textContaining('3 bags'), findsWidgets); + for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3']) { + expect(find.text(bag), findsOneWidget); + } + }); + }); + + group('a partly-accepted kitchen says so', () { + testWidgets('2 of 4 taken is visible on the header', (tester) async { + await pump( + tester, + [ + order('1001', 'Joe', 'Gandhipuram, Coimbatore'), + order('1002', 'Priya', 'RS Puram, Coimbatore'), + order('1003', 'Arun', 'Peelamedu, Coimbatore'), + order('1004', 'Kumar', 'Saibaba Colony, Coimbatore'), + ], + accepted: const {'1002', '1004'}, + ); + + // The rider is mid-decision at one counter; the count of what is still + // undecided is the whole content of that state. + expect(find.textContaining('4 bags'), findsWidgets); + expect(find.textContaining('to accept'), findsWidgets); + }); + }); + + group('it survives a narrow phone and a large font', () { + for (final (width, scale) in const [ + (390.0, 1.0), + (375.0, 1.0), + (320.0, 1.0), + (390.0, 2.0), + (375.0, 2.0), + (320.0, 2.0), + ]) { + testWidgets('${width.toInt()}px at ${scale}x', (tester) async { + await pump( + tester, + [ + order( + '1001', + 'Ramachandran Venkataraman', + 'Saibaba Colony, Coimbatore', + ), + order('1002', 'Priya', 'RS Puram, Coimbatore'), + ], + accepted: const {'1002'}, + selected: const {'1001'}, + width: width, + scale: scale, + ); + // A `RenderFlex` overflow throws, and on a doorstep the stripe bar + // reads as the app having crashed. + expect(tester.takeException(), isNull); + }); + } + }); +} diff --git a/test/lifecycle_boundary_test.dart b/test/lifecycle_boundary_test.dart new file mode 100644 index 0000000..182ae02 --- /dev/null +++ b/test/lifecycle_boundary_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/work_domain.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE ORDER, ONE SCREEN +/// +/// ``` +/// ASSIGNED ─ accept ─▶ ACCEPTED ─ go ─▶ ARRIVED ─ pick up ─▶ PICKED +/// └────────────────── HOME owns all of this ──────────────┘ │ +/// pickup-complete │ +/// ▼ +/// DELIVERIES owns from here on +/// ``` +/// +/// [WorkBoundary] is the single function that decides which side a row is on. +/// Deliveries asked it; **Home never did** — it assigned the raw fetch to its +/// route list and let each card work itself out. So on a kitchen line, where +/// `isWorkComplete` is `delivered || cancelled`, a *picked* stop stayed in +/// Home's route while simultaneously sitting in Deliveries' queue: one bag, two +/// screens, two different next actions. +/// +/// These hold the guarantee itself rather than either screen's filter — the +/// two lists are provably disjoint and provably exhaustive, so no future filter +/// can drift back apart from the other. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map stop(String id, String status) => { + 'orderid': id, + 'pickupid': int.parse(id), + 'sourcename': 'Vidhya Kitchen', + 'orderstatus': status, + }; + + /// The whole day, in every state a row can reach. + List> day() => [ + stop('1001', 'assigned'), + stop('1002', 'accepted'), + stop('1003', 'arrived'), + stop('1004', 'picked'), + stop('1005', 'outfordelivery'), + stop('1006', 'delivered'), + stop('1007', 'cancelled'), + stop('1008', 'skipped'), + ]; + + group('the two lists never overlap', () { + for (final profile in [ServiceProfile.milkMan, ServiceProfile.parcel]) { + test('on ${profile.line.name}', () { + ServiceProfile.setActive(profile); + const accepted = {'1002', '1003'}; + const collected = {'1004'}; + + final home = WorkBoundary.pickupQueue( + day(), + acceptedIds: accepted, + collectedIds: collected, + ).map((s) => s['orderid']).toSet(); + final deliveries = WorkBoundary.deliveryQueue( + day(), + acceptedIds: accepted, + collectedIds: collected, + ).map((s) => s['orderid']).toSet(); + + expect( + home.intersection(deliveries), + isEmpty, + reason: 'an order on both screens is one bag with two next actions', + ); + }); + } + }); + + group('the boundary is pickup-complete, never acceptance', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + test('an accepted booking is Home\'s, not delivery work', () { + // The one rule the delivery screens hang off: accepting is a decision + // about work still to be done, not evidence anything was collected. + // Reading it as a hand-over opened a map, an I'VE ARRIVED and a + // confirmation sheet for a pickup the rider had not made. + expect( + WorkBoundary.domainOf(stop('1002', 'accepted'), acceptedIds: {'1002'}), + WorkDomain.pickup, + ); + }); + + test('an arrived booking is still Home\'s', () { + expect( + WorkBoundary.domainOf(stop('1003', 'arrived'), acceptedIds: {'1003'}), + WorkDomain.pickup, + ); + }); + + test('the collected record moves it, and only after the call landed', () { + // `addCollectedOrderIds` is written by Home *after* pickup-complete comes + // back successful — never optimistically. + expect( + WorkBoundary.domainOf(stop('1004', 'accepted')), + WorkDomain.pickup, + ); + expect( + WorkBoundary.domainOf(stop('1004', 'accepted'), collectedIds: {'1004'}), + WorkDomain.delivery, + ); + }); + + test('the backend status moves it on its own', () { + for (final s in ['picked', 'outfordelivery']) { + expect( + WorkBoundary.domainOf(stop('1004', s)), + WorkDomain.delivery, + reason: '$s is past the counter', + ); + } + }); + }); + + group('closed work belongs to neither', () { + test('a delivered milk-run stop leaves both screens', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + expect( + WorkBoundary.domainOf(stop('1006', 'delivered')), + WorkDomain.closed, + ); + }); + + test('a picked parcel booking is closed; a picked milk run is not', () { + // Collecting IS the job on logistics — the hub delivers it. On a round it + // is the middle of the morning. + ServiceProfile.setActive(ServiceProfile.parcel); + expect(WorkBoundary.domainOf(stop('1004', 'picked')), WorkDomain.closed); + + ServiceProfile.setActive(ServiceProfile.milkMan); + expect( + WorkBoundary.domainOf(stop('1004', 'picked')), + WorkDomain.delivery, + ); + }); + }); + + _sourceNameContract(); + + group('the consignment id survives the pivot', () { + test('it is filed under the order id the door reads', () async { + // `updatePickedStatus` sent no `orderid`, so the mapper filed the + // consignment under the *booking* id while `closeDelivery` looked it up + // by **order** id. Every collected stop was stored under a key nothing + // would ask for, and pressing Delivered answered "this order was never + // picked up on the system" for a bag in the rider's hands. + await rememberConsignmentId('DM-BK-16DA518D-6381', '55123'); + final ids = await getConsignmentIds(); + expect(ids['DM-BK-16DA518D-6381'], '55123'); + }); + + test('an empty id is never stored, so nothing can be fabricated', () async { + await rememberConsignmentId('DM-1', ''); + await rememberConsignmentId('', '55124'); + expect(await getConsignmentIds(), isEmpty); + }); + }); +} + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE READER FOR A PLACE'S NAME +/// +/// `MilkRun.sourceNameOf` read `sourcename` / `kitchenname`; `stopSourceName` +/// read those *and* the CamelCase `SourceName` / `KitchenName` the payload +/// sometimes carries. A booking with only the capitalised key had a counter +/// according to one and none according to the other — and the route card used +/// both in one expression, so the group was not flat (there is a counter) but +/// was titled "pickup" (there is no counter to name). +/// +/// On a device that is a foldable place headed by a leg description, holding +/// one order named after the same stop — exactly the shape the `flat` flag +/// exists to prevent. +void _sourceNameContract() { + group('a place has one name', () { + for (final key in const [ + 'sourcename', + 'SourceName', + 'kitchenname', + 'KitchenName', + ]) { + test('$key is read by both readers', () { + final stop = {'orderid': '1', key: 'Vidhya Kitchen'}; + expect(MilkRun.sourceNameOf(stop), 'Vidhya Kitchen'); + expect(stopSourceName(stop), 'Vidhya Kitchen'); + expect(stopSourceName(stop), MilkRun.sourceNameOf(stop)); + }); + } + + test('no key means no counter, from both', () { + const stop = {'orderid': '1'}; + expect(MilkRun.sourceNameOf(stop), isEmpty); + expect(stopSourceName(stop), isEmpty); + }); + }); +} diff --git a/test/line_capability_test.dart b/test/line_capability_test.dart new file mode 100644 index 0000000..c5cc6ea --- /dev/null +++ b/test/line_capability_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/meal_run_mock.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/providers/pickup/pickup_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A MISSING BACKEND IS A STATE, NOT A FIXTURE +/// +/// The `/miler/*` contract serves bookings and consignments. Kitchens, crates +/// and subscribers are not concepts it has — there is no endpoint that returns +/// a meal run. That is a backend capability that does not exist yet. +/// +/// It was being papered over by [MealRunMock], which was on by default in every +/// debug build. So the meal line *looked* integrated, and the gap was invisible +/// until somebody built a release. +/// +/// These pin the boundary: the fixture is opt-in only, and with it off the +/// provider **refuses** rather than returning an empty list — because "no work +/// today" and "your hub has not switched this on" are different answers and the +/// rider acts differently on each. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() { + MockBackend.enabled = kMockBackend; + MealRunMock.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + group('the capability is named, not implied', () { + test('the lines whose work IS bookings are served', () { + expect(ServiceProfile.parcel.hasBookingsEndpoint, isTrue); + expect(ServiceProfile.delivery.hasBookingsEndpoint, isTrue); + }); + + test('the milk-man line is served too — the hub dispatches it', () { + // This asserted `isFalse`, and that is what kept a real rider empty: + // tenant 13 resolves to this profile, the hub assigns him bookings in the + // console, and the app refused to ask for them. The console reads the + // round from /admin/bookings and assigns with + // /hub/bookings/:id/auto-assign — the same rows /miler/bookings returns. + expect(ServiceProfile.milkMan.hasBookingsEndpoint, isTrue); + }); + + test('the capability does not depend on the fixture', () { + // Whichever way it answers, an opt-in dev flag must not be what decides. + MockBackend.enabled = true; + expect( + ServiceProfile.milkMan.hasBookingsEndpoint, + isTrue, + reason: 'the fixture must never be what answers a backend capability', + ); + }); + }); + + group('with the fixture off, which is the default', () { + setUp(() => MockBackend.enabled = false); + + test('a milk-man rider is not refused — he is dispatched bookings', () { + // Asserted on the capability rather than by calling, for the same reason + // as the parcel case below: the path now proceeds to a real request. + // + // This used to expect a [LineNotServedException]. It was the assertion + // that made the bug look intentional: a rider the hub had assigned work + // to could not be handed it, because his line was believed to have no + // endpoint. The refusal is still reachable — see [LineNotServedException] + // and the test below — it is just no longer this line's answer. + ServiceProfile.setActive(ServiceProfile.milkMan); + expect(ServiceProfile.active.hasBookingsEndpoint, isTrue); + }); + + test('the refusal explains itself', () { + expect( + const LineNotServedException().toString(), + contains('not served by the backend yet'), + ); + }); + + test('a parcel rider is never refused — he has an endpoint', () { + // Asserted on the capability rather than by calling: the parcel path + // proceeds to a real request, and a test that reaches the network is a + // test about the network. + ServiceProfile.setActive(ServiceProfile.parcel); + expect(ServiceProfile.active.hasBookingsEndpoint, isTrue); + }); + + test('the fixture is unreachable while it is off', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + expect(MealRunMock.active, isFalse); + }); + }); + + test( + 'the fixture is reachable only when asked for, and only on its line', + () { + MockBackend.enabled = true; + + ServiceProfile.setActive(ServiceProfile.milkMan); + expect(MealRunMock.active, isTrue); + + // And still not by a parcel rider, whatever else is true. + ServiceProfile.setActive(ServiceProfile.parcel); + expect(MealRunMock.active, isFalse); + }, + ); +} diff --git a/test/load_state_view_test.dart b/test/load_state_view_test.dart new file mode 100644 index 0000000..4f27f8b --- /dev/null +++ b/test/load_state_view_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/load_state.dart'; +import 'package:miler/views/helpers/widgets/load_state_view.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// FOUR SCREENS, ONE ANSWER PER STATE +/// +/// Every screen decided independently what a failure looked like — usually by +/// rendering its empty state, which tells a rider to wait for work that is +/// actually a dead request. These pin the mapping, and in particular the two +/// cases that were previously indistinguishable. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + Future pump( + WidgetTester tester, + LoadState> state, { + Future Function()? onRetry, + }) async { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: LoadStateView>( + state: state, + onRetry: onRetry, + empty: const Text('NOTHING TODAY'), + builder: (v, refreshing) => + Text('${v.join(",")} refreshing=$refreshing'), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('data renders, and says whether a refresh is behind it', ( + tester, + ) async { + await pump(tester, const LoadData(['a', 'b'], refreshing: true)); + expect(find.text('a,b refreshing=true'), findsOneWidget); + }); + + testWidgets('an empty day is the screen\'s own empty state', (tester) async { + await pump(tester, const LoadEmpty>()); + expect(find.text('NOTHING TODAY'), findsOneWidget); + }); + + // ── The distinction the old code could not make ── + testWidgets('a failure is never the empty state', (tester) async { + await pump( + tester, + const LoadFailure>( + LoadFailureKind.server, + message: 'hub is down', + ), + onRetry: () async {}, + ); + + expect( + find.text('NOTHING TODAY'), + findsNothing, + reason: 'telling a rider to wait for work is wrong when the request died', + ); + // The server's own sentence, not one this app invented. + expect(find.text('hub is down'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + }); + + testWidgets('offline says so and offers a retry', (tester) async { + await pump( + tester, + const LoadFailure>(LoadFailureKind.offline), + onRetry: () async {}, + ); + expect(find.text('No connection'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + }); + + testWidgets('an expired session offers no retry', (tester) async { + await pump( + tester, + const LoadFailure>(LoadFailureKind.unauthorized), + onRetry: () async {}, + ); + expect(find.text('Signed out'), findsOneWidget); + expect( + find.text('Try again'), + findsNothing, + reason: 'retrying cannot succeed without signing in again', + ); + }); + + testWidgets('rate limiting does not invite another tap', (tester) async { + await pump( + tester, + const LoadFailure>(LoadFailureKind.rateLimited), + onRetry: () async {}, + ); + expect(find.text('Too many requests'), findsOneWidget); + }); + + // ── Unavailable is neither an error nor an empty day ── + testWidgets('a line with no endpoint gets its own state and no retry', ( + tester, + ) async { + await pump( + tester, + const LoadUnavailable>('Milk Man work is not served yet.'), + onRetry: () async {}, + ); + + expect(find.text('Not available yet'), findsOneWidget); + expect(find.text('Milk Man work is not served yet.'), findsOneWidget); + expect(find.text('NOTHING TODAY'), findsNothing); + expect( + find.text('Try again'), + findsNothing, + reason: 'nothing the rider does changes a missing backend capability', + ); + }); + + testWidgets('the retry actually runs', (tester) async { + var tries = 0; + await pump( + tester, + const LoadFailure>(LoadFailureKind.offline), + onRetry: () async => tries++, + ); + await tester.tap(find.text('Try again')); + await tester.pump(); + expect(tries, 1); + }); +} diff --git a/test/login_mock_path_test.dart b/test/login_mock_path_test.dart new file mode 100644 index 0000000..1a7c7c3 --- /dev/null +++ b/test/login_mock_path_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/providers/auth/auth_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE MOCK BUILD COULD NOT SIGN ANYONE IN +/// +/// `_loginNew` intercepted [MockBackend] with an **early return**, handing back +/// the raw handler payload — `{success, token, user:{…}}`. Everything that +/// makes that payload usable happens *after* that return: the token is stored +/// there, and the response is mapped into the `{status, details:{…}}` envelope +/// that `Login.fromJson` actually reads. +/// +/// So on the mock path `status` was never set — and `Login.fromJson` treats an +/// absent `status` as **false** — and no token was ever stored. The sign-in saw +/// a rejected login with no HTTP status, and reported "Login failed", then +/// "Sign-in did not complete", for **every phone number and every MPIN**. +/// +/// `MOCK_BACKEND` defaults to on in debug, which is how the app is run, so this +/// was the state of sign-in for anyone not building release. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + Get.testMode = true; + // Opt-in now: a bench session asks for the canned backend, and so does a + // test about it. See [kMockBackend]. + MockBackend.enabled = true; + if (!Get.isRegistered()) Get.put(TenantController()); + }); + + tearDown(() => MockBackend.enabled = kMockBackend); + + test('the canned backend is what these tests are exercising', () { + expect( + MockBackend.enabled, + isTrue, + reason: + 'if this is false the test below is not exercising the mock path ' + 'at all', + ); + }); + + test('a mocked verify-pin signs the rider in', () async { + final res = await AuthProvider().loginParsed( + contactNo: '9787698259', + deviceType: 'android', + configId: 1001, + deviceId: 'test-device', + fcmToken: '', + pin: 1234, + pinRaw: '1234', + ); + + expect( + res.status, + isTrue, + reason: + 'the sign-in reads exactly this flag to decide whether the MPIN ' + 'was accepted', + ); + expect(res.userid, isNotNull); + expect(res.userid, isNot(0)); + + final token = await ApiConfig.getToken(); + expect( + token, + isNotNull, + reason: 'without a stored session every later call is unauthenticated', + ); + expect(token, isNotEmpty); + }); + + test('the identity reaches the stores the app reads it from', () async { + await AuthProvider().loginParsed( + contactNo: '9787698259', + deviceType: 'android', + configId: 1001, + deviceId: 'test-device', + fcmToken: '', + pin: 1234, + pinRaw: '1234', + ); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('userid'), isNot(0)); + expect(prefs.getString('contactno'), isNotEmpty); + }); +} diff --git a/test/login_response_shape_test.dart b/test/login_response_shape_test.dart new file mode 100644 index 0000000..51436ca --- /dev/null +++ b/test/login_response_shape_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/login/login.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/providers/auth/auth_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE SHAPE `verify-pin` ACTUALLY RETURNS +/// +/// Captured from a live 200 against `api.doormile.com` for miler 38. Two things +/// about it have bitten this app before and are pinned here so they cannot +/// again: +/// +/// 1. The login lives under **`user` / `user.profile`**. There is no `data` +/// key, whatever the older contract doc said. +/// 2. The body carries **no tenant at all**. The rider's line of work is only +/// in the `tenantid` claim on the JWT — so a build that reads the body and +/// nothing else drops every rider onto the default line. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + // A real token from a live login, kept for its claims. Expired, and a miler + // token authorises nothing an admin route would honour. + const liveToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + 'eyJ1c2VyaWQiOjM4LCJlbWFpbCI6InJhamFuLmFAZG9vcm1pbGUuaW4iLCJyb2xlaWQiOjUs' + 'InRlbmFudGlkIjoxMywiY29uZmlnaWQiOjEwMDEsImV4cCI6MTc4NzEyMDI1NiwiaWF0Ijox' + 'Nzg3MDMzODU2fQ.6tiW6nStmcq2pJGaBmhKajVpZnsv3Y4558ZdaN9dZDo'; + + final live = { + 'success': true, + 'token': liveToken, + 'user': { + 'authname': 'Rajan A', + 'contactno': '9787698259', + 'email': 'rajan.a@doormile.in', + 'userid': 38, + 'profile': { + 'milerprofileid': 75, + 'userid': 38, + 'applocationid': 1, + 'displayname': 'Rajan A', + 'phone': '9787698259', + 'hubid': 1, + 'defaultvehicletype': 'Bike', + 'availabilitystatus': 'On_Pickup', + 'rating': 5, + }, + }, + }; + + test('the token is found where the live handler puts it', () { + final user = live['user'] as Map; + expect( + AuthProvider.tokenFromResponse(live, user, user['profile'] as Map), + liveToken, + ); + }); + + test('the tenant is in the claim, because it is nowhere else', () { + final user = live['user'] as Map; + final profile = user['profile'] as Map; + + expect(live.containsKey('tenantid'), isFalse); + expect(user.containsKey('tenantid'), isFalse); + expect(profile.containsKey('tenantid'), isFalse); + + expect( + ApiConfig.tenantIdFromToken(liveToken), + 13, + reason: 'the only place this rider\'s line of work is stated', + ); + }); + + // ── The legacy envelope the provider maps the live payload into ── + // + // `status` is a bool here. It is the single flag the sign-in reads to decide + // whether the PIN was accepted, and `Login.fromJson` treats a missing one as + // false — so an envelope that forgets to set it reports a good PIN as bad. + test('a mapped success parses as a signed-in rider', () { + final parsed = Login.fromJson({ + 'status': true, + 'code': 200, + 'message': '', + 'details': { + 'userid': 38, + 'riderid': 38, + 'username': 'Rajan A', + 'firstname': 'Rajan', + 'lastname': 'A', + 'contactno': '9787698259', + 'email': 'rajan.a@doormile.in', + 'hubid': 1, + 'applocationid': 1, + 'tenantid': 13, + }, + }); + + expect(parsed.status, isTrue); + expect(parsed.userid, 38); + expect(parsed.tenantid, 13); + expect(parsed.contactno, '9787698259'); + }); + + test('a mapped failure carries the real HTTP status, not a stand-in', () { + // This was hard-coded to 400 whenever the body was not the JSON this + // handler speaks, so a 502 and a 429 and a captive portal all arrived at + // the rider as the same "Login failed" — indistinguishable from a wrong + // MPIN. The sign-in now only calls it a PIN problem on 401/403. + final parsed = Login.fromJson({ + 'status': false, + 'code': 429, + 'message': 'The server returned HTTP 429 with no message.', + 'details': const {}, + }); + + expect(parsed.status, isFalse); + expect(parsed.code, 429); + expect(parsed.message, contains('429')); + }); +} diff --git a/test/login_token_test.dart b/test/login_token_test.dart new file mode 100644 index 0000000..fef26a0 --- /dev/null +++ b/test/login_token_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/providers/auth/auth_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// "LOGIN FAILED" ON A PIN THE SERVER HAD JUST ACCEPTED +/// +/// The sign-in used one question — *is there a bearer token in prefs?* — as its +/// test for *was the PIN correct*. Two different facts, one boolean, and the +/// token was read from exactly one place: `decoded['token']`. +/// +/// So any deployment that nested the token one level down produced a +/// `verify-pin` that succeeded, an app that could not find the session, and a +/// rider told his MPIN was wrong. Every rider, every attempt, with the server +/// agreeing the PIN was right. +/// +/// These pin the shapes. The last one pins the distinction that matters: no +/// token at all is not the same answer as a rejected PIN. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJ0ZW5hbnRpZCI6MTN9.sig'; + + String tokenFrom(Map envelope, {Map? user, Map? profile}) => + AuthProvider.tokenFromResponse(envelope, user, profile); + + test('top level — the shape the handler returns today', () { + expect(tokenFrom({'success': true, 'token': jwt}), jwt); + }); + + test('under data', () { + expect( + tokenFrom({ + 'success': true, + 'data': {'token': jwt}, + }), + jwt, + ); + }); + + test('under user', () { + expect(tokenFrom({'success': true}, user: {'token': jwt}), jwt); + }); + + test('under user.profile', () { + expect(tokenFrom({'success': true}, profile: {'token': jwt}), jwt); + }); + + test('every spelling this contract has worn', () { + for (final key in const [ + 'token', + 'access_token', + 'accessToken', + 'authtoken', + 'authToken', + 'jwt', + 'idToken', + 'id_token', + 'bearer', + ]) { + expect( + tokenFrom({'success': true, key: jwt}), + jwt, + reason: '`$key` must not be the one that locks every rider out', + ); + } + }); + + test('the envelope wins over a nested copy', () { + expect( + tokenFrom({ + 'token': jwt, + 'data': {'token': 'stale'}, + }), + jwt, + ); + }); + + test('a genuinely tokenless response reports nothing, not a bad PIN', () { + // The caller must be able to tell this apart from a rejection — it is an + // integration fault, and it is not the rider's fault. + expect(tokenFrom({'success': true, 'message': 'ok'}), ''); + expect(tokenFrom({'success': true, 'token': ''}), ''); + expect(tokenFrom({'success': true, 'token': ' '}), ''); + expect(tokenFrom({'success': true, 'token': null}), ''); + }); + + test('the string "null" is not a session', () { + expect(tokenFrom({'success': true, 'token': 'null'}), ''); + }); + + test('a shape it has never seen does not throw', () { + expect(tokenFrom(const {}), ''); + expect( + tokenFrom({ + 'success': true, + 'data': 'not a map', + 'token': {}, + }), + isNotNull, + ); + }); +} diff --git a/test/logistics_flow_test.dart b/test/logistics_flow_test.dart new file mode 100644 index 0000000..6b9edd2 --- /dev/null +++ b/test/logistics_flow_test.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/shipment_capture.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE LOGISTICS SHIPMENT DESK +/// +/// ``` +/// arrived ─▶ verify ─▶ FROM ─▶ TO ─▶ weight ─▶ distance ─▶ price +/// ─▶ payment ─▶ INITIATE ORDER ─▶ picked up ─▶ hub +/// ``` +/// +/// ── The rule this file is really about ── +/// +/// **No number is invented.** The fare comes from the pricing table against the +/// real pincodes and the real weight; when the table has no rule for a +/// combination the screen must say so and refuse, because a locally computed +/// figure that looks official on a doorstep is how a rider ends up collecting +/// the wrong money in the company's name. +/// +/// And the mirror, which is the half that breaks quietly: none of this may ever +/// appear on a milk run, where the food was booked and paid for before the +/// rider's shift started. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the quote never invents a fare', () { + test('a priced combination reads the band and charges the lower end', () { + final q = ShipmentQuote.fromData({ + 'found': true, + 'zone': 'Local', + 'service_type': 'Normal', + 'weight': 2.5, + 'currency': 'INR', + 'results': [ + { + 'category': 'General', + 'category_label': 'General goods', + 'min_price': 60, + 'max_price': 90, + }, + ], + }, preferredCategory: 'General'); + + expect(q.found, isTrue); + expect(q.zone, 'Local'); + expect(q.minPrice, 60); + expect(q.maxPrice, 90); + expect(q.isRange, isTrue); + expect( + q.payable, + 60, + reason: + 'the lower end is what the customer was quoted at booking; ' + 'arguing about the difference on a doorstep is the worse trade', + ); + }); + + test('an unpriced combination is not a free shipment', () { + final q = ShipmentQuote.fromData({ + 'found': false, + 'zone': 'National', + 'service_type': 'Express', + 'weight': 400, + 'message': 'no pricing configured for this combination', + 'results': [], + }); + + expect(q.found, isFalse); + expect(q.payable, 0); + expect( + q.message, + isNotEmpty, + reason: 'the rider must be told why, not shown a zero he might collect', + ); + }); + + test('found:true with an empty result set is still unpriced', () { + // A malformed answer must fail the same way a clear "no" does, rather + // than falling through to a zero that reads as free. + final q = ShipmentQuote.fromData({ + 'found': true, + 'zone': 'Local', + 'results': [], + }); + expect(q.found, isFalse); + expect(q.payable, 0); + }); + + test('the rider-named category wins over the cheapest row', () { + final q = ShipmentQuote.fromData({ + 'found': true, + 'zone': 'Local', + 'currency': 'INR', + 'results': [ + {'category': 'General', 'min_price': 50, 'max_price': 70}, + {'category': 'Fragile', 'min_price': 120, 'max_price': 160}, + ], + }, preferredCategory: 'Fragile'); + + expect( + q.payable, + 120, + reason: + 'he said it was fragile; pricing it as general goods ' + 'undercharges the customer and under-insures the parcel', + ); + }); + + test('with no category named, the cheapest row wins', () { + final q = ShipmentQuote.fromData({ + 'found': true, + 'zone': 'Local', + 'results': [ + {'category': 'Fragile', 'min_price': 120, 'max_price': 160}, + {'category': 'General', 'min_price': 50, 'max_price': 70}, + ], + }); + expect(q.payable, 50); + }); + + test('string prices off the wire still parse', () { + final q = ShipmentQuote.fromData({ + 'found': true, + 'zone': 'Regional', + 'results': [ + {'category': 'General', 'min_price': '80.50', 'max_price': '110'}, + ], + }); + expect(q.minPrice, 80.5); + expect(q.maxPrice, 110); + }); + }); + + group('the pricing contract', () { + test('the service tiers are the pricing table\'s, not the booking\'s', () { + // `bookingserviceoptions` uses Normal/Fast/Superfast; the pricing table + // uses Normal/Express, and sending the wrong vocabulary is a 400. + expect(MilerApi.serviceTypes, ['Normal', 'Express']); + }); + + test('the categories match what the handler will accept', () { + expect(MilerApi.parcelCategories, contains('General')); + expect(MilerApi.parcelCategories, contains('Fragile')); + expect(MilerApi.parcelCategories, contains('Documents')); + }); + }); + + group('the shipment desk on screen', () { + Future pumpDesk( + WidgetTester tester, { + Map? booking, + }) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: ShipmentCapturePage( + pickup: + booking ?? + { + 'orderid': '2001', + 'pickupid': 2001, + 'pickupaddress': '9 Mettupalayam Road', + 'pickuppincode': '641002', + 'dropaddress': '12 Cross Cut Road', + 'droppincode': '641012', + }, + ), + ), + ), + ); + await tester.pump(); + // The desk takes a GPS fix on open so the FROM pin is where the rider is + // standing. There is no platform behind the channel here, so that call + // never answers and its timeout is left holding a Timer the binding + // rightly complains about at teardown. Running the clock past the limit + // lets the guard fire and the widget fall back to the booking's own + // coordinates — which is exactly the path a rider with no signal takes. + await tester.pump(const Duration(seconds: 10)); + } + + testWidgets('asks for FROM, TO, weight, distance and price', ( + tester, + ) async { + await pumpDesk(tester); + + // The two ends of the journey, now named on one card with a rail + // between them rather than as two headed groups of loose fields. + expect(find.text('FROM'), findsOneWidget); + expect(find.text('TO'), findsOneWidget); + expect(find.text('THE PARCEL'), findsOneWidget); + expect(find.text('WHAT THE CUSTOMER PAYS'), findsOneWidget); + expect(find.text('Pickup address'), findsOneWidget); + expect(find.text('Delivery address'), findsOneWidget); + expect(find.text('Weight (kg)'), findsOneWidget); + }); + + testWidgets('seeds both addresses so the rider confirms, not transcribes', ( + tester, + ) async { + await pumpDesk(tester); + expect(find.text('9 Mettupalayam Road'), findsOneWidget); + expect(find.text('12 Cross Cut Road'), findsOneWidget); + expect(find.text('641002'), findsOneWidget); + expect(find.text('641012'), findsOneWidget); + }); + + testWidgets('will not continue without a price', (tester) async { + await pumpDesk(tester); + + // No weight typed, so no quote has been asked for and none can exist. + expect(find.text('Weigh the parcel'), findsOneWidget); + + final button = tester.widget( + find.descendant( + of: find.byType(ShipmentCapturePage), + matching: find.byType(ElevatedButton), + ), + ); + expect( + button.onPressed, + isNull, + reason: 'the desk must not hand back a shipment it could not price', + ); + }); + + testWidgets('names what is missing rather than just greying out', ( + tester, + ) async { + await pumpDesk(tester, booking: {'orderid': '2002', 'pickupid': 2002}); + // A disabled button with no reason is the thing riders phone the hub + // about. + expect(find.text('Confirm the pickup address'), findsOneWidget); + }); + }); + + group('none of this reaches a milk run', () { + test('the capabilities are all off', () { + const p = ServiceProfile.milkMan; + expect(p.capturesShipmentAddresses, isFalse); + expect(p.pricesShipment, isFalse); + expect(p.initiatesShipment, isFalse); + expect(p.collectsCash, isFalse); + expect(p.needsVerification, isFalse); + }); + + test('so nothing is ever owed at a milk-run door', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + // Even a booking that arrives carrying an amount reads as zero: the food + // was paid for by subscription and the rider must not ask for money. + expect(stopCollectionAmount({'collectionamt': 450}), 0); + + ServiceProfile.setActive(ServiceProfile.parcel); + expect(stopCollectionAmount({'collectionamt': 450}), 450); + }); + + test('and the milk run has no kitchen collection on logistics', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect(ServiceProfile.active.sourceIsKitchen, isFalse); + expect(ServiceProfile.active.bulkLoadProof, isFalse); + }); + }); + + group('the backend dependencies are declared, not hidden', () { + test('the quoted price has nowhere to be recorded, and says so', () { + expect( + MilerApi.missingFromBackend.any((g) => g.contains('quoted price')), + isTrue, + ); + }); + + test('the delivery OTP cannot be verified, and says so', () { + expect( + MilerApi.missingFromBackend.any((g) => g.contains('delivery OTP')), + isTrue, + ); + }); + }); +} diff --git a/test/meal_run_mock_test.dart b/test/meal_run_mock_test.dart new file mode 100644 index 0000000..7c1f7c9 --- /dev/null +++ b/test/meal_run_mock_test.dart @@ -0,0 +1,250 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/providers/pickup/pickup_provider.dart'; + +import 'package:miler/data/meal_run_mock.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE MEAL DAY THAT HAS NO BACKEND +/// +/// The meal line runs on a day held in memory, because the backend has no +/// kitchens, crates or subscribers to serve. That is a deliberate stand-in, and +/// it carries the risk the app has already been burned by once: a mock layer was +/// deleted from this repo after demo stops kept surfacing on live riders' tabs +/// weeks later. +/// +/// So these tests are mostly about containment — the day is complete enough to +/// walk, and there is no path from a parcel login to any of it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + MealRunMock.reset(); + // The canned day is opt-in now, so the tests that exercise it have to ask + // for it — exactly as a bench session does. The containment group turns it + // back off for the assertions that are about the default. + MockBackend.enabled = true; + }); + tearDown(() { + MealRunMock.reset(); + MockBackend.enabled = kMockBackend; + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + group('containment', () { + test('a parcel rider cannot reach it, whatever else is true', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect(MealRunMock.active, isFalse); + }); + + test('it is off unless somebody asks for it', () { + MockBackend.enabled = kMockBackend; + // ── What this assertion is for ── + // + // This switch was off, and it was off for a real reason: while it was on, + // a meal rider tapped Arrived and Picked up, watched the card advance, + // and the hub console never changed — every status write was recorded on + // the phone and reported as sent. + // + // It survives as a bench tool, but it is **opt in** now. It used to + // default to on in debug and profile, which meant the default development + // experience diverged from production silently: a rider signing in with + // his real number became the fixture's rider, and a whole class of + // contract bugs could not be seen because nothing left the device. + // + // Now it is off unless somebody types the flag, in every build type. + expect( + kMockBackend, + isFalse, + reason: 'the canned day must never be what you get by not deciding', + ); + expect(MockBackend.enabled, isFalse); + // And the two are one switch, so there is no state where the day is + // fictional but the statuses are real, or the other way round. + expect(kMealMockEnabled, MockBackend.enabled); + }); + + test('the meal line is still the only thing that could turn it on', () { + // The containment rule survives the switch: `active` is `enabled && meal + // line`, so re-enabling it for a demo still cannot reach a parcel rider. + // Asserted on the guard itself, since `active` is false either way now. + ServiceProfile.setActive(ServiceProfile.parcel); + expect(ServiceProfile.active.sourceIsKitchen, isFalse); + ServiceProfile.setActive(ServiceProfile.meals); + expect(ServiceProfile.active.sourceIsKitchen, isTrue); + }); + + test('every id announces itself as mock', () { + // `purgeDemoRecords()` matches on exactly this prefix, so anything that + // leaks into a rider's stores is removed at the next launch by code that + // already ships. See the note in `accepted_store.dart`. + ServiceProfile.setActive(ServiceProfile.meals); + for (final s in MealRunMock.stops()) { + expect( + (s['orderid'] as String).toLowerCase(), + startsWith('mock-'), + reason: 'a row that does not say it is mock cannot be purged', + ); + } + }); + }); + + group('the day itself', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.meals)); + + test('is two kitchens, so the grouping has something to group', () { + final kitchens = MealRunMock.stops() + .map((s) => stopSourceName(s)) + .toSet(); + // One kitchen hides every question the sections exist to answer. + expect(kitchens.length, 2); + }); + + test('every stop is a drop, with a bag the rider can read off a box', () { + for (final s in MealRunMock.stops()) { + expect(stopKindOf(s).isDelivery, isTrue); + expect(stopBagLabel(s), isNotEmpty); + expect((s['pickupcustomer'] as String), isNotEmpty); + expect((s['pickupaddress'] as String), contains('Coimbatore')); + } + }); + + test('nothing is owed at any door', () { + // A subscriber paid the client by the month. A rupee figure here would be + // a question the rider cannot answer. + for (final s in MealRunMock.stops()) { + expect(stopCollectionAmount(s), 0); + } + }); + + test('it starts undecided — the rider has done nothing yet', () { + for (final s in MealRunMock.stops()) { + expect(s['orderstatus'], 'assigned'); + } + }); + + test('it groups into one trip in route order', () { + final trips = Trip.groupIntoTrips(MealRunMock.stops()); + expect(trips, hasLength(1)); + expect(trips.single.stops.map((s) => s['step']), [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]); + }); + }); + + group('walking it', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.meals)); + + test('a status change sticks, and only touches its own stop', () { + MealRunMock.setStatus('MOCK-M-1001', 'accepted'); + + final byId = { + for (final s in MealRunMock.stops()) s['orderid']: s['orderstatus'], + }; + expect(byId['MOCK-M-1001'], 'accepted'); + expect(byId['MOCK-M-1002'], 'assigned'); + }); + + test('the controllers key on the booking id they actually carry', () { + // Every status method has an int `pickupId` and often no order id at all, + // so the mock has to resolve one from the other or half the ladder writes + // nothing. + expect(MealRunMock.setStatusByPickupId(1002, 'arrived'), isTrue); + final row = MealRunMock.stops().firstWhere( + (s) => s['orderid'] == 'MOCK-M-1002', + ); + expect(row['orderstatus'], 'arrived'); + }); + + test('the whole ladder runs: assigned → accepted → arrived → picked', () { + const id = 'MOCK-M-1003'; + String statusOf() => + MealRunMock.stops().firstWhere( + (s) => s['orderid'] == id, + )['orderstatus'] + as String; + + expect(statusOf(), 'assigned'); + for (final rung in ['accepted', 'arrived', 'picked']) { + MealRunMock.setStatus(id, rung); + expect(statusOf(), rung); + } + }); + + test('reset puts the day back, so a demo can be run twice', () { + MealRunMock.setStatus('MOCK-M-1001', 'picked'); + MealRunMock.reset(); + expect( + MealRunMock.stops().every((s) => s['orderstatus'] == 'assigned'), + isTrue, + ); + }); + + test('handing out rows never hands out the master copy', () { + // The screens stamp compliance and proof blocks onto stop maps in place. + // Sharing the originals would carry one run's leftovers into the next. + MealRunMock.stops().first['pickupcustomer'] = 'SCRIBBLED'; + expect(MealRunMock.stops().first['pickupcustomer'], isNot('SCRIBBLED')); + }); + }); + + // ── The wiring, not just the fixture ── + // + // The day above is only useful if the app actually receives it. These walk the + // real path — provider, transport, auth — with no network available, which is + // exactly the situation a developer running `flutter run` on a bench is in. + group('the app runs on it', () { + test( + 'the queue provider serves the day without touching the network', + () async { + ServiceProfile.setActive(ServiceProfile.meals); + final stops = await PickupProvider().getPickupQueues(); + + expect(stops, hasLength(9)); + expect( + stops.every((s) => (s['orderid'] as String).startsWith('MOCK-')), + isTrue, + reason: + 'every row is self-identifying, so a leak is purged at launch', + ); + }, + ); + + test('a parcel rider still gets the real call, and no fixture', () async { + ServiceProfile.setActive(ServiceProfile.parcel); + // The transport answers rather than the fixture: an empty booking list, + // not somebody else's kitchens. Inventing parcel work is the one thing + // this mock must not do. + final stops = await PickupProvider().getPickupQueues(); + expect(stops, isEmpty); + }); + + test('every write is answered without a server', () async { + final res = await MilerApi.pickupComplete(1001); + expect(res.ok, isTrue); + expect(res.status, 200); + }); + + test('signing in works with no directory to ask', () async { + expect(MockBackend.enabled, isTrue); + // The profile the mock hands back is what puts the build on the meal + // line, so the login and the day agree about which app the rider is in. + expect(MockBackend.rider['tenantname'], 'DailyGrubs'); + }); + }); +} diff --git a/test/miler_api_contract_test.dart b/test/miler_api_contract_test.dart new file mode 100644 index 0000000..2b5c82d --- /dev/null +++ b/test/miler_api_contract_test.dart @@ -0,0 +1,324 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHAT THIS CLIENT ACTUALLY PUTS ON THE WIRE +/// +/// Several fields in this contract are quietly load-bearing, and every one of +/// them fails *silently* if it drifts — the request is accepted, or rejected +/// with a generic message, and nothing on the phone says which field was wrong: +/// +/// • `device_token` is snake_case where everything around it is not. +/// • Telemetry numbers go as **strings**; real JSON numbers fail to parse. +/// • Telemetry bodies must carry **no `userid`** — identity is the token, and +/// a body `userid` is how one rider's GPS could be written under another's. +/// • Availability sends `Break`, not the obvious `On_Break`. +/// • `vehicle-required` takes **query parameters**, not a body. +/// • Consignment logs are a JSON **array**, even for one entry. +/// +/// None of it was covered, because `_send` reached the `http` package's +/// top-level functions and could only be exercised by letting it hit the +/// network. `MilerApi.client` is the seam; these assert the request itself. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List sent; + + /// Captures every request and answers with [body]. + void stub({ + Map body = const {'success': true}, + int code = 200, + }) { + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + return http.Response( + json.encode(body), + code, + headers: const {'content-type': 'application/json'}, + ); + }); + } + + Map lastBody() => + json.decode(sent.single.body) as Map; + + setUp(() async { + // The canned backend intercepts *above* the HTTP client, so with it on + // there is no request to assert against — which is precisely why these + // fields went uncovered for so long. + MockBackend.enabled = false; + SharedPreferences.setMockInitialValues({'authtoken': 'test-token'}); + await ApiConfig.setToken('test-token'); + stub(); + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + }); + + group('authentication', () { + test('every authenticated call carries the bearer token', () async { + await MilerApi.getProfile(); + expect(sent.single.headers['Authorization'], 'Bearer test-token'); + }); + + test( + 'login and verify-pin do not, because there is nothing to carry yet', + () async { + await MilerApi.login('9787698259'); + expect(sent.single.headers.containsKey('Authorization'), isFalse); + + stub(); + await MilerApi.verifyPin(phone: '9787698259', pin: '1234'); + expect(sent.single.headers.containsKey('Authorization'), isFalse); + }, + ); + + test('the rider surface is always partition 1001', () async { + await MilerApi.login('9787698259'); + expect(lastBody()['configid'], 1001); + }); + + test('the PIN goes as a string, so a leading zero survives', () async { + await MilerApi.verifyPin(phone: '9787698259', pin: '0512'); + expect(lastBody()['pin'], '0512'); + expect(lastBody()['pin'], isA()); + }); + + // ── reset-pin is admin/ops, and the rider app must never reach it ── + // + // It was once open, and reset-pin followed by verify-pin took over any + // rider account given nothing but a phone number. + test('no source file in the app ever builds a reset-pin request', () { + // Asserted against the source rather than a stub, because the failure + // mode is somebody *adding* the call — and a runtime test only catches a + // route that is already being exercised. Comments explaining why it is + // absent are expected; a request path is not. + final offenders = []; + for (final f in Directory('lib').listSync(recursive: true)) { + if (f is! File || !f.path.endsWith('.dart')) continue; + for (final line in f.readAsLinesSync()) { + final code = line.trim(); + if (code.startsWith('//') || code.startsWith('///')) continue; + // The route, not the word: explaining in a log message why the app + // does not call it is exactly the right thing to do. + if (code.contains('/miler/reset-pin')) offenders.add(f.path); + } + } + expect( + offenders, + isEmpty, + reason: + 'reset-pin needs an admin token; reset-pin followed by ' + 'verify-pin once took over any rider account given only a phone ' + 'number', + ); + }); + }); + + group('profile and device', () { + test('the device token field is snake_case', () async { + await MilerApi.setDeviceToken('fcm-abc'); + expect(sent.single.method, 'PUT'); + expect(sent.single.url.path, endsWith('/miler/device-token')); + expect(lastBody().keys, contains('device_token')); + expect(lastBody().keys, isNot(contains('deviceToken'))); + expect(lastBody()['device_token'], 'fcm-abc'); + }); + }); + + group('duty and availability', () { + test( + 'availability sends `status`, and the break value is `Break`', + () async { + await MilerApi.setAvailability('Break'); + expect(lastBody()['status'], 'Break'); + expect( + MilerApi.availabilityStatuses, + contains('Break'), + reason: 'the obvious guess, On_Break, is the wrong one', + ); + expect(MilerApi.availabilityStatuses, isNot(contains('On_Break'))); + }, + ); + + test('ending duty and breaks are PUT, starting them is POST', () async { + await MilerApi.startDuty(lat: 11.0, lon: 76.9); + expect(sent.single.method, 'POST'); + + stub(); + await MilerApi.endDuty(); + expect(sent.single.method, 'PUT'); + + stub(); + await MilerApi.endBreak(); + expect(sent.single.method, 'PUT'); + }); + }); + + group('telemetry', () { + test('every numeric-looking field is serialized as a string', () async { + await MilerApi.postLog( + latitude: 11.0168, + longitude: 76.9558, + speed: 4.2, + heading: 180.0, + accuracy: 8.0, + battery: 73, + ); + + final b = lastBody(); + for (final key in const [ + 'latitude', + 'longitude', + 'speed', + 'heading', + 'accuracy', + 'battery', + ]) { + expect(b[key], isA(), reason: '$key must not be a JSON number'); + } + expect(b['latitude'], '11.0168'); + expect(b['battery'], '73'); + }); + + test('no telemetry body carries a userid', () async { + await MilerApi.postLog(latitude: 1, longitude: 2); + expect( + lastBody().keys, + isNot(contains('userid')), + reason: + 'identity is the token; a body userid is how one rider\'s GPS ' + 'gets written under another\'s', + ); + + stub(); + await MilerApi.postStatus('Available'); + expect(lastBody().keys, isNot(contains('userid'))); + }); + + test('consignment logs are a JSON array, even for one entry', () async { + await MilerApi.postConsignmentLogs([ + const ConsignmentLogEntry( + consignmentId: 42, + status: 'Out_for_Delivery', + latitude: 11.0, + longitude: 76.9, + ), + ]); + final decoded = json.decode(sent.single.body); + expect(decoded, isA>()); + expect((decoded as List).single, isA>()); + }); + + test('location writes lat/lon/pincode/speed/heading', () async { + await MilerApi.pushLocation( + latitude: 11.0, + longitude: 76.9, + pincode: '641004', + speed: 3.0, + heading: 90.0, + ); + expect(sent.single.method, 'PUT'); + expect(sent.single.url.path, endsWith('/miler/location')); + expect(lastBody().keys, containsAll(['latitude', 'longitude'])); + }); + }); + + group('pickup and delivery', () { + test('vehicle-required uses query parameters, not a body', () async { + await MilerApi.vehicleRequired( + 101, + type: 'Truck', + reason: 'Parcel too large for the box', + ); + expect( + sent.single.url.queryParameters, + isNotEmpty, + reason: + 'this one takes query parameters where its neighbours take ' + 'a JSON body', + ); + expect(sent.single.body, anyOf(isEmpty, equals('null'))); + }); + + test('pickup-complete is its own transition, not a status write', () async { + await MilerApi.pickupComplete(101); + expect(sent.single.method, 'POST'); + expect( + sent.single.url.path, + endsWith('/miler/bookings/101/pickup-complete'), + ); + }); + + test('a consignment is delivered and skipped by its own id', () async { + await MilerApi.deliver( + 7, + deliveredToName: 'Joe Mathew', + lat: 11.0, + lon: 76.9, + ); + expect(sent.single.url.path, endsWith('/miler/consignments/7/deliver')); + expect(lastBody()['deliveredtoname'], 'Joe Mathew'); + // ── The OTP is conditional, and its absence is a real answer ── + // + // Nothing generates a delivery OTP, so the handler records whether one + // was presented rather than checking it. A caller without one — a + // milk-run drop handed over with no code — must not invent a placeholder. + expect(lastBody().keys, isNot(contains('otp'))); + + stub(); + await MilerApi.skipConsignment(7, reason: 'Customer not available'); + expect(sent.single.url.path, endsWith('/miler/consignments/7/skip')); + expect(lastBody()['reason'], 'Customer not available'); + }); + }); + + group('errors are values, not exceptions', () { + test( + 'a rejected call comes back as a failed result with the message', + () async { + stub(body: {'success': false, 'message': 'incorrect PIN'}, code: 401); + final res = await MilerApi.getProfile(); + + expect(res.ok, isFalse); + expect(res.status, 401); + expect(res.message, 'incorrect PIN'); + }, + ); + + test('a body that is not JSON does not throw', () async { + MilerApi.client = MockClient( + (_) async => http.Response('502 Bad Gateway', 502), + ); + final res = await MilerApi.getProfile(); + expect(res.ok, isFalse); + expect(res.status, 502); + }); + + test('a transport failure is a result, not an uncaught throw', () async { + MilerApi.client = MockClient((_) async => throw const SocketFailure()); + final res = await MilerApi.getProfile(); + expect(res.ok, isFalse); + expect(res.status, 0); + }); + }); +} + +/// Stands in for a dead network without depending on `dart:io` in a test that +/// otherwise needs none. +class SocketFailure implements Exception { + const SocketFailure(); +} diff --git a/test/milk_man_flow_test.dart b/test/milk_man_flow_test.dart new file mode 100644 index 0000000..0fd83c0 --- /dev/null +++ b/test/milk_man_flow_test.dart @@ -0,0 +1,418 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE BRIEF'S OWN SCENARIO, WALKED THROUGH +/// +/// Six orders, three kitchens: +/// +/// ``` +/// Vidhya ×3 ┐ +/// Priyanka ×2 ├─ accept all six (per order, across kitchens) +/// ABC ×1 ┘ +/// │ +/// ├─ Vidhya: arrived → picked ×3 ─▶ DELIVERIES (Priyanka/ABC stay) +/// ├─ Priyanka: arrived → picked ×2 ─▶ DELIVERIES +/// └─ ABC: arrived → picked ×1 ─▶ DELIVERIES +/// │ +/// arrived → delivered ─▶ ACTIVITY +/// ``` +/// +/// The three rules this file holds, all of which were got wrong at some point: +/// +/// 1. **Acceptance is per order.** Six accepted orders stay six visible cards; +/// they are not replaced by three kitchen cards. +/// 2. **Pickup is per kitchen.** One counter at a time — and finishing one +/// kitchen must NOT wait for the others. +/// 3. **`picked` is the middle of the morning.** It goes to DELIVERIES, and +/// only `delivered` reaches Activity. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ServiceProfile.setActive(ServiceProfile.milkMan); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map order({ + required String id, + required String kitchen, + required String kitchenId, + String status = 'assigned', + bool withDrop = true, + }) => { + 'orderid': id, + 'pickupid': int.parse(id), + 'orderstatus': status, + 'sourceid': kitchenId, + 'sourcename': kitchen, + 'pickupaddress': '$kitchen, Trichy Road', + 'pickupcustomer': 'Receiver $id', + 'pickupcontactno': '98765432$id', + 'quantity': 1, + 'pickuplat': 11.01, + 'pickuplon': 76.95, + if (withDrop) 'droplat': 11.05, + if (withDrop) 'droplon': 76.99, + if (withDrop) 'dropaddress': 'Flat $id, Anna Nagar', + }; + + /// The brief's six. + List> sixOrders() => [ + order(id: '101', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'), + order(id: '102', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'), + order(id: '103', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'), + order(id: '104', kitchen: 'Priyanka Kitchen', kitchenId: 'K2'), + order(id: '105', kitchen: 'Priyanka Kitchen', kitchenId: 'K2'), + order(id: '106', kitchen: 'ABC Kitchen', kitchenId: 'K3'), + ]; + + Set idsOf(Iterable> s) => + s.map(MilkRun.idOf).toSet(); + + group('1–7 · six orders arrive, all six are accepted, all six stay', () { + test('acceptance is per order and crosses kitchens', () { + final stops = sixOrders(); + final accepted = idsOf(stops); + expect( + accepted.length, + 6, + reason: + 'he takes on the whole morning in one press, whichever ' + 'counters it comes from', + ); + }); + + test('six accepted orders remain six — not three kitchen cards', () { + final stops = sixOrders(); + final accepted = idsOf(stops); + + // Every accepted order is still individually addressable and still + // outstanding work. Collapsing them into one card per kitchen would lose + // the receiver, the drop address and the order id — the things the rider + // reads on the card. + final outstanding = MilkRun.outstandingPickups( + stops, + acceptedIds: accepted, + collectedIds: const {}, + ); + expect(outstanding.length, 6); + expect(idsOf(outstanding), accepted); + for (final s in outstanding) { + expect(MilkRun.sourceNameOf(s), isNotEmpty); + expect(s['pickupcustomer'], isNotEmpty); + expect(s['pickupcontactno'], isNotEmpty); + } + }); + + test('but they are still knowable as three kitchens', () { + final stops = sixOrders(); + final keys = stops.map(MilkRun.sourceKeyOf).toSet(); + expect(keys.length, 3); + expect(MilkRun.sameSourceAs(stops[0], stops).length, 3); // Vidhya + expect(MilkRun.sameSourceAs(stops[3], stops).length, 2); // Priyanka + expect(MilkRun.sameSourceAs(stops[5], stops).length, 1); // ABC + }); + }); + + group('9–11 · navigate and the details sheet', () { + test('any Vidhya order navigates to Vidhya Kitchen', () { + final stops = sixOrders(); + // He does not need a special kitchen card — any of the three will do. + for (final s in MilkRun.sameSourceAs(stops[0], stops)) { + expect(MilkRun.navigatesToCustomer(s), isFalse); + expect(MilkRun.navigationLabel(s), 'Vidhya Kitchen'); + expect(MilkRun.navigationTarget(s)?.lat, 11.01); + } + }); + + test('the sheet has everything it needs, from the payload', () { + // Nothing here is invented; each is a field the adapter carries. + final s = sixOrders().first; + expect(MilkRun.sourceNameOf(s), 'Vidhya Kitchen'); // pickup kitchen + expect(s['pickupaddress'], isNotEmpty); // pickup address + expect(s['pickupcustomer'], 'Receiver 101'); // receiver + expect(s['pickupcontactno'], isNotEmpty); // phone → Call + expect(s['dropaddress'], isNotEmpty); // drop address + expect(MilkRun.idOf(s), '101'); // order id + expect(s['quantity'], 1); // quantity + expect(stopStatusOf(s), StopStatus.assigned); // status + }); + + test('a missing phone is absent, never fabricated', () { + final s = sixOrders().first..remove('pickupcontactno'); + expect( + s['pickupcontactno'], + isNull, + reason: 'the Call action hides rather than dialling a made-up number', + ); + }); + }); + + group('12–17 · Vidhya: arrive, pick, and straight to DELIVERIES', () { + test('the pickup set is bounded to one kitchen', () { + final stops = sixOrders(); + final vidhya = MilkRun.sameSourceAs(stops[0], stops); + expect(idsOf(vidhya), {'101', '102', '103'}); + expect( + idsOf(vidhya).intersection({'104', '105', '106'}), + isEmpty, + reason: 'Priyanka and ABC are different rides and different counters', + ); + }); + + test('arrived is not picked', () { + // §14: arrival and collection are separate events. + final s = sixOrders().first..['orderstatus'] = 'arrived'; + expect(stopStatusOf(s), StopStatus.arrived); + expect(stopStatusOf(s).isPicked, isFalse); + expect(MilkRun.nextActionLabel(StopStatus.arrived), 'Picked up'); + }); + + test('picking Vidhya leaves Priyanka and ABC on Home', () { + final stops = sixOrders(); + final accepted = idsOf(stops); + final collected = {'101', '102', '103'}; + + final stillToCollect = MilkRun.outstandingPickups( + stops, + acceptedIds: accepted, + collectedIds: collected, + ); + expect(idsOf(stillToCollect), {'104', '105', '106'}); + expect(stillToCollect.map(MilkRun.sourceNameOf).toSet(), { + 'Priyanka Kitchen', + 'ABC Kitchen', + }); + }); + + test('and the three picked ones are live deliveries immediately', () { + // §24 — the rule that must NOT wait for the other kitchens. + final collected = {'101', '102', '103'}; + for (final id in collected) { + expect(collected.contains(id), isTrue); + } + // They navigate to the customer now, not back to the kitchen. + final s = sixOrders().first; + expect(MilkRun.navigatesToCustomer(s, collectedIds: collected), isTrue); + expect(MilkRun.navigationTarget(s, collectedIds: collected)?.lat, 11.05); + }); + }); + + group('18 · the picked card lands on DELIVERIES, not Activity', () { + // ── Both filters, from opposite sides, on one fixture ── + // + // The deliveries list keeps what is NOT complete; Activity keeps what IS. + // They read the same test, so a stop can only ever be on one of them — and + // the bug was that `picked` answered "complete" on a line where it means + // "in the rider's box". + + test('the deliveries list keeps a picked milk-run stop', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final s = sixOrders().first..['orderstatus'] = 'picked'; + // `pickups.dart` builds its list with `!st.isFinishedPickup`. + expect(stopStatusOf(s).isFinishedPickup, isFalse); + }); + + test('and Activity drops it', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final s = sixOrders().first..['orderstatus'] = 'picked'; + // `activity_page.dart` builds its list with `.isFinishedPickup`. + expect(stopStatusOf(s).isFinishedPickup, isFalse); + }); + + test('the backend\'s own post-pickup status behaves the same', () { + // `pickup-complete` leaves the booking `Converted_To_Consignment`, which + // the adapter maps to `picked`. The live payload must not put the order + // in history either. + ServiceProfile.setActive(ServiceProfile.milkMan); + final mapped = ApiConfig.legacyStatusFromNew('Converted_To_Consignment'); + expect(mapped, 'picked'); + expect(stopStatusFromRaw(mapped).isWorkComplete, isFalse); + }); + + test('once delivered the two swap', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final s = sixOrders().first..['orderstatus'] = 'delivered'; + expect(stopStatusOf(s).isFinishedPickup, isTrue); + }); + + test('on logistics picked goes to Activity, as it always did', () { + ServiceProfile.setActive(ServiceProfile.parcel); + final s = sixOrders().first..['orderstatus'] = 'picked'; + expect(stopStatusOf(s).isFinishedPickup, isTrue); + }); + }); + + group('18b · the status vocabulary underneath', () { + test('picked is live work on this line', () { + final s = sixOrders().first..['orderstatus'] = 'picked'; + expect( + stopStatusOf(s).isWorkComplete, + isFalse, + reason: + 'the bug: a bag collected at 11:40 was filed as history before ' + 'the rider had left the counter', + ); + }); + + test('delivered is what completes it', () { + final s = sixOrders().first..['orderstatus'] = 'delivered'; + expect(stopStatusOf(s).isWorkComplete, isTrue); + }); + }); + + group('20–22 · the rest of the kitchens, then all six on the round', () { + test('each kitchen adds its orders as it is worked', () { + final stops = sixOrders(); + final accepted = idsOf(stops); + final collected = {}; + + for (final batch in [ + {'101', '102', '103'}, + {'104', '105'}, + {'106'}, + ]) { + collected.addAll(batch); + final left = MilkRun.outstandingPickups( + stops, + acceptedIds: accepted, + collectedIds: collected, + ); + expect(left.length, 6 - collected.length); + } + + expect(collected.length, 6); + expect( + MilkRun.outstandingPickups( + stops, + acceptedIds: accepted, + collectedIds: collected, + ), + isEmpty, + ); + }); + }); + + group('23–25 · the delivery leg', () { + test('navigation targets the customer', () { + final s = sixOrders().first..['orderstatus'] = 'picked'; + expect(MilkRun.navigationLabel(s), 'Receiver 101'); + expect(MilkRun.navigationTarget(s)?.lng, 76.99); + }); + + test('customer arrival is a different state from kitchen arrival', () { + expect(stopStatusFromRaw('arrived'), StopStatus.arrived); + expect(stopStatusFromRaw('delivery_arrived'), StopStatus.deliveryArrived); + expect(StopStatus.arrived, isNot(StopStatus.deliveryArrived)); + expect(MilkRun.nextActionLabel(StopStatus.deliveryArrived), 'Delivered'); + }); + + test('the delivery arrival is local, and says so', () { + // §20 — documented, not faked. No call is made and no success invented. + expect(MilkRun.deliveryArrivalIsLocalOnly, isTrue); + }); + + test('delivered files it in Activity with the right verb', () async { + await addCompletedBookings([ + {'orderid': '101', 'pickupcustomer': 'Receiver 101'}, + ]); + final done = await getCompletedBookings(); + expect(done.single['orderstatus'], 'delivered'); + expect(stopStatusOf(done.single).isWorkComplete, isTrue); + }); + + test('a delivered order keys on the consignment, not the booking', () { + // The same trap `bookingassignmentid` sets on the accept side. + final s = sixOrders().first..['consignmentid'] = '900'; + expect(MilkRun.consignmentIdOf(s), '900'); + expect(MilkRun.consignmentIdOf(sixOrders().first), ''); + }); + }); + + group('26 · a milk run never sees the logistics desk', () { + test('no payment, pricing, address capture or verification', () { + const p = ServiceProfile.milkMan; + expect(p.collectsCash, isFalse); + expect(p.pricesShipment, isFalse); + expect(p.capturesShipmentAddresses, isFalse); + expect(p.initiatesShipment, isFalse); + expect(p.needsVerification, isFalse); + }); + + test('and nothing is ever owed at a milk-run door', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + expect(stopCollectionAmount({'collectionamt': 450}), 0); + }); + }); + + group('27 · logistics is untouched', () { + test('its whole desk is still on', () { + const p = ServiceProfile.parcel; + expect(p.capturesShipmentAddresses, isTrue); + expect(p.pricesShipment, isTrue); + expect(p.initiatesShipment, isTrue); + expect(p.collectsCash, isTrue); + expect(p.needsVerification, isTrue); + }); + + test('accepted logistics work still moves off Home', () { + expect(ServiceProfile.parcel.handoffAt, HandoffPoint.accepted); + expect(ServiceProfile.milkMan.handoffAt, HandoffPoint.collected); + }); + + test('it has no kitchen and never drives to its customer', () { + const p = ServiceProfile.parcel; + expect(p.sourceIsKitchen, isFalse); + expect(p.deliversToCustomer, isFalse); + }); + + test('and picked still finishes it', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect(StopStatus.picked.isWorkComplete, isTrue); + }); + }); + + group('the wire carries what the grouping needs', () { + test('the adapter passes source, drop and consignment through', () { + final s = ApiConfig.pickupFromBooking({ + 'bookingid': 42, + 'bookingreference': 'DM-42', + 'status': 'Miler_Assigned', + 'pickupaddress': 'Vidhya Kitchen, Trichy Road', + 'deliveryaddress': 'Flat 3, Anna Nagar', + 'sourceid': 'K1', + 'sourcename': 'Vidhya Kitchen', + 'consignmentid': 900, + 'customerphone': '9876543210', + }); + expect(MilkRun.sourceNameOf(s), 'Vidhya Kitchen'); + expect(MilkRun.sourceKeyOf(s), 'id:K1'); + expect(MilkRun.consignmentIdOf(s), '900'); + expect(s['pickupcontactno'], '9876543210'); + expect(s['dropaddress'], 'Flat 3, Anna Nagar'); + }); + + test('a booking without source fields does not invent one', () { + // BACKEND DEPENDENCY: sourceid/sourcename are not deployed. Until they + // are, every order groups under one unnamed bucket — visibly wrong, and + // deliberately not papered over with a guessed kitchen name. + final s = ApiConfig.pickupFromBooking({ + 'bookingid': 43, + 'bookingreference': 'DM-43', + 'pickupaddress': '9 Mettupalayam Road', + }); + expect(MilkRun.sourceNameOf(s), ''); + expect(MilkRun.sourceKeyOf(s), 'none'); + }); + }); +} diff --git a/test/milk_run_test.dart b/test/milk_run_test.dart new file mode 100644 index 0000000..83a7e7c --- /dev/null +++ b/test/milk_run_test.dart @@ -0,0 +1,594 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE MILK RUN, END TO END +/// +/// ``` +/// 6 orders ─ accept ─┬─ Kitchen 1 ×5 ─ arrived ─ picked ─▶ DELIVERIES +/// └─ Kitchen 2 ×5 ─ arrived ─ picked ─▶ DELIVERIES +/// │ +/// arrived ─ delivered ─▶ ACTIVITY +/// ``` +/// +/// Three units, three different shapes, and the file exists because they were +/// being conflated: +/// +/// • **Acceptance is per order** and crosses kitchens freely. +/// • **Pickup is per kitchen** — one ride, one counter, one handover — so a +/// selection must never span two of them. +/// • **Delivery is per order** again, each bag to its own door. +/// +/// And the lifecycle rule underneath all of it: `picked` is the *middle* of a +/// milk-run morning. An order collected at a kitchen is a live delivery, not +/// history — it goes to DELIVERIES, and only `delivered` sends it to Activity. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ServiceProfile.setActive(ServiceProfile.milkMan); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// One assigned stop, in the shape the adapter produces. + Map stop({ + required String id, + required String kitchen, + String status = 'assigned', + String? consignmentId, + }) => { + 'orderid': id, + 'pickupid': int.parse(id.replaceAll(RegExp(r'\D'), '')), + 'orderstatus': status, + 'sourceid': kitchen.hashCode.toString(), + 'sourcename': kitchen, + 'pickupcustomer': 'Customer $id', + 'pickuplat': 11.01, + 'pickuplon': 76.95, + if (consignmentId != null) 'consignmentid': consignmentId, + }; + + /// Two kitchens, five orders each — the brief's own example. + List> tenOrders() => [ + for (var i = 1; i <= 5; i++) stop(id: '100$i', kitchen: 'Kitchen 1'), + for (var i = 6; i <= 10; i++) stop(id: '10$i', kitchen: 'Kitchen 2'), + ]; + + Set idsOf(Iterable> stops) => + stops.map((s) => s['orderid'] as String).toSet(); + + group('pickup is bounded by the kitchen', () { + // ── The three units ── + // + // Acceptance is per order and crosses kitchens freely. Pickup does not: a + // selection spanning two counters would post "arrived" for a shop the rider + // is nowhere near and mark bags collected that are still in another + // kitchen's warmer. Delivery is per order again. + + test('orders from one kitchen group together', () { + final stops = tenOrders(); + final vidhya = MilkRun.sameSourceAs(stops.first, stops); + expect(vidhya.length, 5); + expect(vidhya.every((s) => s['sourcename'] == 'Kitchen 1'), isTrue); + }); + + test('and orders from another kitchen are not swept in', () { + final stops = tenOrders(); + final vidhya = MilkRun.sameSourceAs(stops.first, stops); + expect( + vidhya.any((s) => s['sourcename'] == 'Kitchen 2'), + isFalse, + reason: 'Kitchen 2 is a different ride and a different counter', + ); + expect(MilkRun.sameSource(stops.first, stops.last), isFalse); + }); + + test('the stable id decides, not the display name', () { + // Two kitchens can share a name across areas; the id is what the hub + // controls, so it is what the app compares. + final a = {'orderid': '1', 'sourceid': 'K1', 'sourcename': 'Vidhya'}; + final b = {'orderid': '2', 'sourceid': 'K2', 'sourcename': 'Vidhya'}; + expect(MilkRun.sameSource(a, b), isFalse); + }); + + test('a name-only payload still groups', () { + final a = {'orderid': '1', 'sourcename': 'Vidhya Kitchen'}; + final b = {'orderid': '2', 'sourcename': 'vidhya kitchen'}; + expect(MilkRun.sameSource(a, b), isTrue); + }); + + test( + 'no source at all is one honest bucket, never a fabricated kitchen', + () { + final a = {'orderid': '1'}; + final b = {'orderid': '2'}; + expect(MilkRun.sourceKeyOf(a), 'none'); + expect(MilkRun.sameSource(a, b), isTrue); + expect(MilkRun.sourceNameOf(a), ''); + }, + ); + + test('a zero pickuplocationid is not an id', () { + // The adapter emits '0' for an absent location; treating it as a real + // key would put every unsourced order in one bogus group. + expect(MilkRun.sourceKeyOf({'pickuplocationid': '0'}), 'none'); + expect(MilkRun.sourceKeyOf({'pickuplocationid': '7'}), 'id:7'); + }); + + test('collected work is NOT held back for the other kitchens', () { + // The rule §24 turns on: three Kitchen-1 bags collected at 11:40 are + // three live deliveries, even though Kitchen 2 has not been visited. + final stops = tenOrders(); + final k1 = idsOf(stops.take(5)); + for (final s in stops.take(5)) { + expect( + k1.contains(MilkRun.idOf(s)), + isTrue, + reason: 'on the deliveries tab the moment it is in his hands', + ); + } + // And the rest are still his to collect. + final outstanding = MilkRun.outstandingPickups( + stops, + acceptedIds: idsOf(stops), + collectedIds: k1, + ); + expect(outstanding.length, 5); + expect(outstanding.every((s) => s['sourcename'] == 'Kitchen 2'), isTrue); + }); + }); + + group('where Navigate goes', () { + test('to the kitchen before collection', () { + final s = stop(id: '1001', kitchen: 'Kitchen 1'); + expect(MilkRun.navigatesToCustomer(s), isFalse); + final target = MilkRun.navigationTarget(s); + expect(target?.lat, 11.01); + expect(MilkRun.navigationLabel(s), 'Kitchen 1'); + }); + + test( + 'a nameless pickup is titled by its neighbourhood, never "pickup"', + () { + // The live backend sends no sourcename today, and the literal 'pickup' + // this used to fall back to became the 24sp headline of the rider's + // first group — the exact regression the project rule forbids. + final s = { + 'orderid': '1001', + 'type': 'pickup', + 'pickupaddress': '12/4, RS Puram, Coimbatore, Tamil Nadu 641002', + }; + expect( + MilkRun.navigationLabel(s), + 'RS Puram', + reason: + 'the first non-numeric address component is a place; ' + '"pickup" is a leg description', + ); + + // A component carrying its own house number is titled by the street. + expect( + MilkRun.navigationLabel({ + 'orderid': '1002', + 'type': 'pickup', + 'pickupaddress': '124 Gandhipuram Main Road, Coimbatore 641012', + }), + 'Gandhipuram Main Road', + ); + + // No address either → a capitalised noun, still never the raw leg word. + expect( + MilkRun.navigationLabel({'orderid': '1', 'type': 'pickup'}), + 'Pickup', + ); + }, + ); + + test('to the customer after collection', () { + final s = stop(id: '1001', kitchen: 'Kitchen 1') + ..['droplat'] = 11.05 + ..['droplon'] = 76.99; + expect(MilkRun.navigatesToCustomer(s, collectedIds: {'1001'}), isTrue); + final target = MilkRun.navigationTarget(s, collectedIds: {'1001'}); + expect(target?.lat, 11.05); + expect(target?.lng, 76.99); + expect( + MilkRun.navigationLabel(s, collectedIds: {'1001'}), + 'Customer 1001', + ); + }); + + test('the backend status alone flips it, without the local set', () { + final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked') + ..['droplat'] = 11.05 + ..['droplon'] = 76.99; + expect(MilkRun.navigatesToCustomer(s), isTrue); + expect(MilkRun.navigationTarget(s)?.lat, 11.05); + }); + + test('null rather than the wrong end when coordinates are missing', () { + // A Navigate button that quietly opens the other end of the journey is + // worse than a disabled one: the rider only finds out on arrival. + final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked'); + expect(MilkRun.navigatesToCustomer(s), isTrue); + expect(MilkRun.navigationTarget(s), isNull); + }); + + test('logistics never navigates to a customer', () { + ServiceProfile.setActive(ServiceProfile.parcel); + final s = stop(id: '2001', kitchen: '', status: 'picked') + ..['droplat'] = 11.05 + ..['droplon'] = 76.99; + expect( + MilkRun.navigatesToCustomer(s), + isFalse, + reason: 'its collected parcel goes to a hub; somebody else delivers it', + ); + expect(MilkRun.navigationTarget(s)?.lat, 11.01); + }); + }); + + group('the ladder a stop climbs', () { + test('accepted → arrived → picked → out for delivery → delivered', () { + final s = stop(id: '1001', kitchen: 'Kitchen 1'); + + expect( + MilkRun.stageOf(s, acceptedIds: const {}, collectedIds: const {}), + StopStatus.assigned, + ); + expect( + MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: const {}), + StopStatus.accepted, + ); + + s['orderstatus'] = 'arrived'; + expect( + MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: const {}), + StopStatus.arrived, + ); + + expect( + MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: {'1001'}), + StopStatus.picked, + ); + expect( + MilkRun.stageOf( + s, + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: {'1001'}, + ), + StopStatus.outForDelivery, + ); + expect( + MilkRun.stageOf( + s, + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: {'1001'}, + deliveredIds: {'1001'}, + ), + StopStatus.delivered, + ); + }); + + test('picked up is never mistaken for delivered', () { + // The crate is in his hands at 11:50 and the last box goes through a door + // at 13:20. Reporting the first as the second bills the client for ninety + // minutes of work that has not happened. + expect(StopStatus.picked.isDelivered, isFalse); + expect(StopStatus.picked.isTerminal, isTrue); + expect(StopStatus.delivered.isDelivered, isTrue); + expect(StopStatus.outForDelivery.isDeliveryLeg, isTrue); + expect(StopStatus.picked.isDeliveryLeg, isFalse); + }); + + test( + 'the pickup arrival and the delivery arrival are different states', + () { + expect(stopStatusFromRaw('arrived'), StopStatus.arrived); + expect( + stopStatusFromRaw('delivery_arrived'), + StopStatus.deliveryArrived, + ); + expect( + StopStatus.arrived, + isNot(StopStatus.deliveryArrived), + reason: 'one is a kitchen counter, the other a customer door', + ); + }, + ); + + test( + 'the backend spelling of out-for-delivery folds to the same state', + () { + for (final raw in [ + 'Out_for_Delivery', + 'out for delivery', + 'outfordelivery', + ]) { + expect( + stopStatusFromRaw(raw), + StopStatus.outForDelivery, + reason: raw, + ); + } + }, + ); + + test('each rung offers the button that belongs to it', () { + expect(MilkRun.nextActionLabel(StopStatus.accepted), 'Arrived'); + expect(MilkRun.nextActionLabel(StopStatus.arrived), 'Picked up'); + expect(MilkRun.nextActionLabel(StopStatus.outForDelivery), 'Arrived'); + expect(MilkRun.nextActionLabel(StopStatus.deliveryArrived), 'Delivered'); + expect(MilkRun.nextActionLabel(StopStatus.delivered), isNull); + }); + }); + + group('a collected order is on DELIVERIES, never Activity', () { + // ── The bug this group exists for ── + // + // `picked` at a kitchen was being filed as completed work, so the order + // left the deliveries list and appeared in the rider's history before he + // had left the counter. Activity's filter and the deliveries list read the + // same test from opposite sides, so it broke both at once. + + test('picked is not finished work on a milk run', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + expect(StopStatus.picked.isWorkComplete, isFalse); + expect(StopStatus.delivered.isWorkComplete, isTrue); + expect(StopStatus.cancelled.isWorkComplete, isTrue); + }); + + test('picked IS finished work on logistics', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect( + StopStatus.picked.isWorkComplete, + isTrue, + reason: 'collecting the parcel is the job; the hub takes it from there', + ); + }); + + test('a picked milk-run stop is still live work', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked'); + expect(stopStatusOf(s).isWorkComplete, isFalse); + expect(stopStatusOf(s).isPicked, isTrue); + }); + }); + + group('the round, on the device', () { + test('releasing marks the load and finishing clears it', () async { + expect(await getOutForDeliveryOrderIds(), isEmpty); + + await addOutForDeliveryOrderIds(['1001', '1002', '1003']); + expect(await getOutForDeliveryOrderIds(), {'1001', '1002', '1003'}); + + await removeOutForDeliveryOrderIds(['1002']); + expect(await getOutForDeliveryOrderIds(), {'1001', '1003'}); + }); + + test('a finished day leaves nothing behind for tomorrow', () async { + await addCollectedOrderIds(['1001']); + await addOutForDeliveryOrderIds(['1001']); + await addNotLoadedOrderIds(['1002']); + + await clearServiceRunState(); + + expect(await getCollectedOrderIds(), isEmpty); + expect(await getOutForDeliveryOrderIds(), isEmpty); + expect(await getNotLoadedOrderIds(), isEmpty); + }); + + test('a milk-run stop is filed as delivered, not picked', () async { + await addCompletedBookings([ + {'orderid': '1001', 'pickupcustomer': 'A'}, + ]); + final done = await getCompletedBookings(); + expect(done.single['orderstatus'], 'delivered'); + expect(stopStatusOf(done.single), StopStatus.delivered); + }); + + test('a logistics stop is filed as picked', () async { + ServiceProfile.setActive(ServiceProfile.parcel); + await addCompletedBookings([ + {'orderid': '2001', 'pickupcustomer': 'B'}, + ]); + final done = await getCompletedBookings(); + expect(done.single['orderstatus'], 'picked'); + }); + }); + + group('the consignment id survives the trip from the API', () { + test('the adapter carries source, pincodes and consignment through', () { + final stop = ApiConfig.pickupFromBooking({ + 'bookingid': 42, + 'bookingreference': 'DM-42', + 'status': 'Converted_To_Consignment', + 'pickupaddress': 'Kitchen 1, RS Puram', + 'pickuppincode': '641002', + 'deliveryaddress': '12 Cross Cut Road', + 'deliverypincode': '641012', + 'sourceid': '7', + 'sourcename': 'Kitchen 1', + 'consignmentid': 900, + }); + + // These four were the ones being dropped: without them every milk-run + // stop grouped into one nameless pile and no drop could be closed. + expect(stop['sourceid'], '7'); + expect(stop['sourcename'], 'Kitchen 1'); + expect(stop['consignmentid'], '900'); + expect(stop['pickuppincode'], '641002'); + expect(stop['droppincode'], '641012'); + }); + + test('a booking with no source groups as no source, not as a guess', () { + final stop = ApiConfig.pickupFromBooking({ + 'bookingid': 43, + 'bookingreference': 'DM-43', + 'pickupaddress': '9 Mettupalayam Road', + }); + expect(stop['sourcename'], ''); + expect(stop['sourceid'], ''); + expect(stop['consignmentid'], ''); + }); + + test('MilkRun reads the consignment the adapter wrote', () { + expect(MilkRun.consignmentIdOf({'consignmentid': '900'}), '900'); + expect(MilkRun.consignmentIdOf({}), ''); + }); + }); + + // ── The label follows the leg, not the payload ── + // + // `ApiConfig.pickupFromBooking` stamps `type: pickup` on every row it builds, + // because a v1 booking *is* a first-mile pickup and the backend has no + // per-stop type to send. On a milk run that made a bag already in the rider's + // box describe itself as a pickup: the delivery card offered **Start Pickup**, + // the confirmation sheet asked him to **Confirm pickup**, and the write path + // behind that button took the pickup branch — he was being asked to collect + // something he was carrying. + // ── Which screen a rung is worked on ── + // + // The defect: after accepting on Home, the Deliveries tab offered a live + // strip for the stop. Tapping it opened the single-stop map, then I'VE + // ARRIVED, then a confirmation sheet — a second, contradictory route up a + // ladder whose rungs are supposed to be one bulk slide on Home, at the + // counter, for the whole stack of bags at once. It also posted the booking + // ACTIVE from the tab that is meant to be the rider's load. + group('worksOnOwnScreen', () { + Map booking(String status) => { + 'orderid': 'B1', + 'type': 'pickup', + 'orderstatus': status, + 'kitchenname': 'Vidhya Kitchen', + }; + + test('a kitchen collection is worked on Home, not on its own screen', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + for (final status in ['assigned', 'accepted', 'arrived']) { + expect( + MilkRun.worksOnOwnScreen(booking(status)), + isFalse, + reason: '$status is a rung of the bulk ladder on Home', + ); + } + }); + + test('the delivery leg is worked on its own screen', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + // Both ways a bag is known to be in his hands. + expect(MilkRun.worksOnOwnScreen(booking('picked')), isTrue); + expect( + MilkRun.worksOnOwnScreen(booking('accepted'), collectedIds: {'B1'}), + isTrue, + ); + }); + + test('every logistics stop is worked on its own screen', () { + // Nothing is batched on that line: one customer, one parcel, one + // shipment raised at the door. The rule must not leak across. + ServiceProfile.setActive(ServiceProfile.parcel); + for (final status in ['assigned', 'accepted', 'arrived', 'picked']) { + expect( + MilkRun.worksOnOwnScreen(booking(status)), + isTrue, + reason: status, + ); + } + }); + }); + + group('workingKind', () { + Map booking(String status) => { + 'orderid': 'B1', + 'pickupcustomer': 'Joe', + // Exactly what the adapter writes. + 'type': 'pickup', + 'orderstatus': status, + 'kitchenname': 'Vidhya Kitchen', + }; + + test( + 'a collected milk-run order is a delivery, whatever the payload says', + () { + ServiceProfile.setActive(ServiceProfile.meals); + final stop = booking('picked'); + + expect( + stopKindOf(stop), + StopKind.pickup, + reason: 'what the payload says', + ); + expect( + MilkRun.workingKind(stop), + StopKind.delivery, + reason: 'what the rider is actually doing', + ); + expect(MilkRun.workingKind(stop).startCtaLabel, 'Start Delivery'); + expect(MilkRun.workingKind(stop).completeLabel, 'Delivered'); + }, + ); + + test('the collected set is what the delivery screen actually has', () { + // ── The status is not the record on that screen ── + // + // A stop on the work tab comes out of the *accepted* store, which + // normalises every row it holds to `accepted`. What records the hand-over + // is the collected set — so asking the leg without it reads a bag already + // in the rider's box as still needing collection, which is exactly how + // the delivery card came to offer **Start Pickup**. + ServiceProfile.setActive(ServiceProfile.meals); + final onTheTab = booking('accepted'); + + expect(MilkRun.workingKind(onTheTab), StopKind.pickup); + expect( + MilkRun.workingKind(onTheTab, collectedIds: {'B1'}).startCtaLabel, + 'Start Delivery', + ); + expect( + MilkRun.workingKind(onTheTab, collectedIds: {'B1'}).completeLabel, + 'Delivered', + ); + }); + + test('the local collected set counts, before any status lands', () { + ServiceProfile.setActive(ServiceProfile.meals); + final stop = booking('accepted'); + + // Straight after the pickup slide the server may still say `accepted`; + // the rider is holding the bag either way. + expect(MilkRun.workingKind(stop), StopKind.pickup); + expect( + MilkRun.workingKind(stop, collectedIds: {'B1'}), + StopKind.delivery, + ); + }); + + test('before collection it is still a pickup', () { + ServiceProfile.setActive(ServiceProfile.meals); + expect(MilkRun.workingKind(booking('accepted')), StopKind.pickup); + }); + + test('a parcel booking is untouched', () { + ServiceProfile.setActive(ServiceProfile.parcel); + // A collected parcel goes to a hub and is delivered by somebody else, so + // the rider never drives to its customer and the word never changes. + expect(MilkRun.workingKind(booking('picked')), StopKind.pickup); + expect( + MilkRun.workingKind(booking('picked')).startCtaLabel, + 'Start Pickup', + ); + }); + }); +} diff --git a/test/mutation_guard_test.dart b/test/mutation_guard_test.dart new file mode 100644 index 0000000..4f57446 --- /dev/null +++ b/test/mutation_guard_test.dart @@ -0,0 +1,254 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/mutation_guard.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE PRESS, ONE REQUEST +/// +/// Every business-critical action here is a POST the backend is not idempotent +/// about. A rider presses them on a moving bike, with gloves, on a screen he +/// cannot look at for long — so a double tap is not an edge case. Two +/// `duty/start` calls are a server error; two `payment` calls are two payments; +/// two `accept` calls race each other to decide what the list shows. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late int calls; + late Completer release; + + /// A server that does not answer until the test lets it, so a second press + /// genuinely lands while the first is still in flight. + void stubSlow({int code = 200, Map? body}) { + calls = 0; + release = Completer(); + MilerApi.client = MockClient((_) async { + calls++; + await release.future; + return http.Response( + json.encode(body ?? {'success': true}), + code, + headers: const {'content-type': 'application/json'}, + ); + }); + } + + setUp(() async { + MockBackend.enabled = false; + MutationGuard.reset(); + SharedPreferences.setMockInitialValues({'authtoken': 'test-token'}); + await ApiConfig.setToken('test-token'); + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + MutationGuard.reset(); + MilerApi.onUnauthorized = null; + }); + + test('a double tap on accept sends one request', () async { + stubSlow(); + + final first = MilerApi.acceptAssignment(1042); + final second = MilerApi.acceptAssignment(1042); + + release.complete(); + final results = await Future.wait([first, second]); + + expect(calls, 1, reason: 'the second press must not reach the server'); + expect(results.where((r) => r.ok).length, 1); + + // ── The dropped press is a failure, not a silent null ── + // + // A caller that does not know about the guard still has to take its error + // path rather than reading nothing as success. + final dropped = results.firstWhere((r) => !r.ok); + expect(dropped.message, contains('already going through')); + }); + + test('two different assignments are not blocked by each other', () async { + stubSlow(); + + final a = MilerApi.acceptAssignment(1); + final b = MilerApi.acceptAssignment(2); + release.complete(); + await Future.wait([a, b]); + + expect(calls, 2, reason: 'the guard is keyed by resource, not by verb'); + }); + + test( + 'the key frees on completion, so a genuine retry goes through', + () async { + stubSlow(); + final first = MilerApi.acceptAssignment(7); + release.complete(); + await first; + + stubSlow(); + final retry = MilerApi.acceptAssignment(7); + release.complete(); + await retry; + + expect(calls, 1, reason: 'the second stub saw a fresh request'); + }, + ); + + test('a failed mutation frees its key too', () async { + // A guard that leaks on failure is worse than no guard: the rider's retry + // would be dropped for the rest of the session. + MilerApi.client = MockClient((_) async => throw const _Dead()); + await MilerApi.pickupComplete(55); + expect(MutationGuard.isBusy('pickup-complete:55'), isFalse); + }); + + test('duty start and end share one key, in both directions', () async { + stubSlow(); + final start = MilerApi.startDuty(lat: 11, lon: 76); + final end = MilerApi.endDuty(); + release.complete(); + await Future.wait([start, end]); + + expect( + calls, + 1, + reason: + 'a rider tapping the switch again because the first tap "did ' + 'nothing" is the commonest way to double-fire duty', + ); + }); + + group('every non-idempotent mutation is guarded', () { + Future expectGuarded( + String label, + Future Function() call, + ) async { + stubSlow(); + final a = call(); + final b = call(); + release.complete(); + await Future.wait([a, b]); + expect(calls, 1, reason: '$label is not protected against a double tap'); + } + + test('the pickup flow', () async { + await expectGuarded('reached', () => MilerApi.reached(9)); + await expectGuarded('parcel', () => MilerApi.submitParcels(9, const [])); + await expectGuarded( + 'payment', + () => MilerApi.submitPayment(9, paymentMode: 'Cash', amount: 120), + ); + await expectGuarded('pickup-complete', () => MilerApi.pickupComplete(9)); + }); + + test('the delivery flow', () async { + await expectGuarded( + 'deliver', + () => MilerApi.deliver(3, deliveredToName: 'Joe'), + ); + await expectGuarded( + 'skip', + () => MilerApi.skipConsignment(3, reason: 'Nobody home'), + ); + }); + + test('availability and breaks', () async { + await expectGuarded( + 'availability', + () => MilerApi.setAvailability('Available'), + ); + await expectGuarded('break', () => MilerApi.startBreak('Personal')); + }); + + test('reject and cancel', () async { + await expectGuarded( + 'reject', + () => MilerApi.rejectAssignment(4, reason: 'Too far'), + ); + await expectGuarded( + 'cancel', + () => MilerApi.cancelBooking(4, reason: 'Shop closed'), + ); + }); + }); + + group('a 401 ends the session, once, centrally', () { + test('the dead token is dropped and the app is told', () async { + var told = 0; + MilerApi.onUnauthorized = () => told++; + MilerApi.client = MockClient( + (_) async => http.Response( + json.encode({'success': false, 'message': 'token expired'}), + 401, + headers: const {'content-type': 'application/json'}, + ), + ); + + final res = await MilerApi.getProfile(); + + expect(res.ok, isFalse); + expect(res.status, 401); + expect(told, 1); + expect( + await ApiConfig.getToken(), + isNull, + reason: 'nothing may retry with a token the server has refused', + ); + }); + + test('a wrong PIN does not log anybody out', () async { + var told = 0; + MilerApi.onUnauthorized = () => told++; + MilerApi.client = MockClient( + (_) async => http.Response( + json.encode({'success': false, 'message': 'incorrect PIN'}), + 401, + headers: const {'content-type': 'application/json'}, + ), + ); + + await MilerApi.verifyPin(phone: '9787698259', pin: '0000'); + + expect( + told, + 0, + reason: + 'a 401 on an auth route is about a credential, not an expired ' + 'session — clearing state here is a logout in response to a typo', + ); + }); + + test('a 429 is a failure, not a session end', () async { + var told = 0; + MilerApi.onUnauthorized = () => told++; + MilerApi.client = MockClient( + (_) async => http.Response( + json.encode({'success': false, 'message': 'rate limited'}), + 429, + headers: const {'content-type': 'application/json'}, + ), + ); + + final res = await MilerApi.assignments(); + expect(res.ok, isFalse); + expect(res.status, 429); + expect(res.message, 'rate limited'); + expect(told, 0); + expect(await ApiConfig.getToken(), isNotNull); + }); + }); +} + +class _Dead implements Exception { + const _Dead(); +} diff --git a/test/mutation_refresh_test.dart b/test/mutation_refresh_test.dart new file mode 100644 index 0000000..ebd9301 --- /dev/null +++ b/test/mutation_refresh_test.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/controllers/pickups_controller.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/load_state.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_repository.dart'; +import 'package:miler/providers/pickup/pickup_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// AFTER A MUTATION, THE SERVER IS ASKED — NOT ASSUMED +/// +/// `triggerRefresh` bumped a counter and every screen re-read *its own* copy of +/// the day. So accepting a stop on Home updated Home, and Bookings and Activity +/// carried on showing the state from before the tap until their own poll came +/// round — and if a screen's copy was still inside its freshness window, the +/// re-read returned the pre-mutation answer. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final repo = WorkRepository.instance; + final provider = PickupProvider(); + late int requests; + late String serverStatus; + + void stub() { + requests = 0; + MilerApi.client = MockClient((req) async { + // The repository also reads `/miler/assignments` for the hub's stop + // sequence — a different endpoint answering a different question. Not the + // day's request, so not counted here; these assertions are about how many + // times the *day* is fetched. + if (req.url.path.endsWith('/miler/assignments')) { + return http.Response( + json.encode({'success': true, 'data': const []}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + requests++; + return http.Response( + json.encode({ + 'success': true, + 'data': [ + { + 'bookingid': 'A', + 'orderid': 'A', + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '12 Main Rd', + 'status': serverStatus, + }, + ], + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + }); + } + + setUp(() async { + MockBackend.enabled = false; + ServiceProfile.setActive(ServiceProfile.parcel); + SharedPreferences.setMockInitialValues({'authtoken': 't', 'userid': 1}); + await ApiConfig.setToken('t'); + repo.resetForTest(); + serverStatus = 'Miler_Assigned'; + stub(); + Get.testMode = true; + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + repo.resetForTest(); + Get.reset(); + }); + + test( + 'a mutation invalidates the shared copy, so the next read is fresh', + () async { + await provider.getCurrentPickups(); + expect(requests, 1); + + // Inside the freshness window, a second read is served from the copy — + // which is the behaviour that stops rebuilds duplicating requests. + await provider.getCurrentPickups(); + expect(requests, 1); + + // A mutation must break that: the next read has to reach the API, or the + // screens carry on showing the state from before the tap. + final controller = Get.put(PickupsController()); + serverStatus = 'Picked_Up'; + controller.triggerRefresh(); + + // Let the invalidation's own load finish. + await repo.load(); + + expect( + requests, + greaterThan(1), + reason: 'the mutation must send the next read to the API', + ); + final state = repo.state; + expect(state, isA>>>()); + expect( + state.valueOrNull!.single['orderstatus'], + 'Picked up', + reason: 'the screens take the state the server confirmed', + ); + }, + ); + + test( + 'all three screens see the post-mutation state, not just the actor', + () async { + // Home reads. + final home = await provider.getPickupQueues(); + expect(home.single['orderstatus'], 'assigned'); + + serverStatus = 'Converted_To_Consignment'; + await repo.invalidate(); + + // Bookings and Activity read afterwards and get the same new answer, + // from the same object. + final bookings = await provider.getCurrentPickups(); + final activity = await provider.getPickupQueuesPicked(); + + expect(bookings.single['orderstatus'], 'picked'); + expect(identical(bookings, activity), isTrue); + }, + ); +} diff --git a/test/no_embedded_secrets_test.dart b/test/no_embedded_secrets_test.dart new file mode 100644 index 0000000..30344e4 --- /dev/null +++ b/test/no_embedded_secrets_test.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// NO CREDENTIAL MAY BE A LITERAL IN THE BINARY +/// +/// Three files carried a DigitalOcean Spaces access key and secret key as +/// `const String` literals, compiled into every APK the app has shipped. +/// Anyone with the binary could read them with `strings`, and the pair was +/// read-write on the whole `doormile` bucket — every rider's proof photo, for +/// every tenant, readable and deletable by anybody who had installed the app +/// once. +/// +/// They come from `--dart-define` now. This is the guard that stops the next +/// one going in, because the failure is silent: the code works perfectly with a +/// literal, and nothing about it looks wrong in review. +/// +/// **This test does not undo the exposure.** The pair is in this repository's +/// history and has been distributed; it has to be rotated, and the upload +/// belongs behind a server-issued signed URL. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + test('no source file assigns a credential-shaped literal', () { + // Names that mean "this is a secret", assigned a quoted literal of any + // length that is not obviously empty. + final assignment = RegExp( + r'''(accessKey|secretKey|apiKey|apiSecret|password|privateKey|''' + r'''clientSecret|authToken)\s*=\s*["'][^"']{8,}["']''', + caseSensitive: false, + ); + + final offenders = []; + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final lines = entity.readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + final code = lines[i].trim(); + if (code.startsWith('//') || code.startsWith('///')) continue; + // `String.fromEnvironment('SPACES_KEY')` is the fix, not the fault. + if (code.contains('fromEnvironment')) continue; + if (assignment.hasMatch(code)) { + offenders.add('${entity.path}:${i + 1}'); + } + } + } + + expect( + offenders, + isEmpty, + reason: + 'a credential compiled into the app is readable by anyone who ' + 'installs it — pass it with --dart-define and rotate anything that ' + 'has already shipped', + ); + }); +} diff --git a/test/offline_banner_test.dart b/test/offline_banner_test.dart index d5abb79..a08adec 100644 --- a/test/offline_banner_test.dart +++ b/test/offline_banner_test.dart @@ -89,7 +89,10 @@ void main() { testWidgets('online, it occupies no space at all', (tester) async { await pump(tester, offline: false); expect(find.byType(OfflineBanner), findsNothing); - expect(tester.getRect(find.text('45, Lake View Apartments')).top, lessThan(20)); + expect( + tester.getRect(find.text('45, Lake View Apartments')).top, + lessThan(20), + ); }); testWidgets('the strip carries a readable label on its fill', (tester) async { diff --git a/test/one_bag_per_order_test.dart b/test/one_bag_per_order_test.dart new file mode 100644 index 0000000..7a02b95 --- /dev/null +++ b/test/one_bag_per_order_test.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE PICKUP ORDER = ONE BAG +/// +/// The operational rule the pickup half of the app rests on. Five orders from a +/// kitchen is five bags is five deliveries, and the rider must be able to check +/// that against a shelf without doing arithmetic. +/// +/// It is asserted rather than commented because the failure mode is quiet: a +/// screen that shows a backend `Quantity`, or counts "meals", or aggregates a +/// crate figure, looks perfectly reasonable in review and puts two numbers in +/// front of a rider that can disagree. He then has no way to tell which one is +/// wrong while standing at the counter. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map order( + String id, { + required String customer, + String kitchen = 'Vidhya Kitchen', + String kitchenId = 'K1', + String status = 'accepted', + String? printedBag, + // A backend item count deliberately unequal to one, to prove nothing reads + // it as a bag count. + int quantity = 4, + }) => { + 'orderid': id, + 'step': int.parse(id), + 'pickupcustomer': customer, + 'pickupaddress': 'Gandhi Nagar, Peelamedu', + 'dropaddress': '12, SNS Colony, Peelamedu', + 'orderstatus': status, + 'type': 'delivery', + 'quantity': quantity, + 'kitchenname': kitchen, + 'kitchenid': kitchenId, + if (printedBag != null) 'baglabel': printedBag, + 'pickuplat': 11.02, + 'pickuplon': 76.99, + }; + + final fiveOrders = [ + order('1', customer: 'Joe'), + order('2', customer: 'Arun'), + order('3', customer: 'Priya'), + order('4', customer: 'Kumar'), + order('5', customer: 'Ravi'), + ]; + + group('the rule itself', () { + test('bags are counted from orders, never stored', () { + expect(BagManifest.countLabel(5), '5 bags'); + expect(BagManifest.countLabel(1), '1 bag'); + }); + + test('every order gets exactly one bag, in group order', () { + final lines = BagManifest.forGroup(fiveOrders); + + expect(lines.length, 5, reason: 'five orders is five manifest lines'); + expect(lines.map((l) => l.bag).toList(), [ + 'Bag 1', + 'Bag 2', + 'Bag 3', + 'Bag 4', + 'Bag 5', + ]); + expect(lines.map((l) => l.customer).toList(), [ + 'Joe', + 'Arun', + 'Priya', + 'Kumar', + 'Ravi', + ]); + // Order 1 → Bag 1 → Joe, and it stays that way. + expect(lines.first.orderId, '1'); + expect( + lines.map((l) => l.bag).toSet().length, + 5, + reason: 'two orders may never claim the same bag', + ); + }); + + test('a label the kitchen actually printed wins over the position', () { + final lines = BagManifest.forGroup([ + order('1', customer: 'Joe', printedBag: 'DG-1042'), + order('2', customer: 'Arun'), + ]); + expect(lines[0].bag, 'DG-1042', reason: 'what is on the physical bag'); + expect(lines[1].bag, 'Bag 2'); + }); + + test('the item quantity is never read as a bag count', () { + // Each of these orders carries `quantity: 4`. Four is an item count; the + // bag count is one, per order, always. + final lines = BagManifest.forGroup(fiveOrders); + expect(lines.length, 5); + expect(BagManifest.countLabel(lines.length), '5 bags'); + }); + + test('a bag belongs to its own counter, not the whole route', () { + // Third order from the second kitchen is *its* Bag 3, not the route's + // eighth — the rider counts one shelf at a time. + final kitchenTwo = [ + order('6', customer: 'Meena', kitchen: 'Anna Kitchen', kitchenId: 'K2'), + order('7', customer: 'Sai', kitchen: 'Anna Kitchen', kitchenId: 'K2'), + order('8', customer: 'Devi', kitchen: 'Anna Kitchen', kitchenId: 'K2'), + ]; + expect(BagManifest.bagFor(kitchenTwo[2], kitchenTwo), 'Bag 3'); + }); + }); + + group('the rule on screen', () { + // The widget is built *inside* the ScreenUtilInit builder: `.w`/`.h` read + // an initialised ScreenUtil, and a card constructed before the first frame + // throws a LateInitializationError rather than laying out. + Future pump(WidgetTester tester, Widget Function() child) async { + tester.view.physicalSize = const Size(1080, 2600); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: child())), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the pickup heading states orders and bags together', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pump( + tester, + () => TripCard( + trip: Trip.fromStops(id: 't', stops: fiveOrders), + acceptedIds: const {'1', '2', '3', '4', '5'}, + onToggleSelect: (_) {}, + ).buildStops(), + ); + + expect(find.text('5 bags'), findsOneWidget); + // Nothing anywhere offers a second, contradictable count. + expect(find.textContaining('meals'), findsNothing); + expect(find.textContaining('Quantity'), findsNothing); + }); + + // ── Ported off the "NEXT ACTION" head, which no longer exists ── + // + // These two pumped `TripCard.buildNextAction()`: a glass card above the + // route carrying the place name at 25sp, `5 bags`, the travel + // time and a full-width **Navigate to pickup** — while the group for that + // same kitchen, ~200pt below it, carried the identical count and the + // identical button. The card is gone and the timeline node absorbed + // everything it said, so the contracts are asserted where they now live. + // + // Both are *stronger* here: they run against the whole route block Home + // renders rather than one widget in isolation, so `findsOneWidget` is now a + // real count of the journey actions on screen — which is the duplication + // the redesign removed. + testWidgets('the pickup node counts the same way, and acts once', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pump( + tester, + () => TripCard( + trip: Trip.fromStops(id: 't', stops: fiveOrders), + acceptedIds: const {'1', '2', '3', '4', '5'}, + onNavigateStop: (_) {}, + ).buildStops(), + ); + + expect(find.text('Vidhya Kitchen'), findsOneWidget); + expect(find.text('5 bags'), findsOneWidget); + // One obvious next action on the whole screen, and it is the journey. + expect(find.bySemanticsLabel('Navigate to this pickup'), findsOneWidget); + }); + + testWidgets('and offers no journey while everything is still pending', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pump( + tester, + () => TripCard( + trip: Trip.fromStops( + id: 't', + stops: [ + for (final o in fiveOrders) {...o, 'orderstatus': 'ASSIGNED'}, + ], + ), + onNavigateStop: (_) {}, + ).buildStops(), + ); + + // Triage is the tick and the accept bar's job. "Navigate" before he has + // taken anything on is an action for work that is not his yet — and the + // node still names the kitchen and its load, so nothing is hidden. + expect(find.bySemanticsLabel('Navigate to this pickup'), findsNothing); + expect(find.text('Vidhya Kitchen'), findsOneWidget); + expect(find.text('5 bags'), findsOneWidget); + }); + + testWidgets('the confirmation names every order and its bag', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pump( + tester, + () => StopActionSheet( + stage: StopStatus.accepted, + placeName: 'Vidhya Kitchen', + count: fiveOrders.length, + lines: BagManifest.forGroup(fiveOrders), + onConfirm: (_) async => null, + ), + ); + + expect(find.text('Arrived at Vidhya Kitchen'), findsOneWidget); + expect(find.text('5 bags'), findsOneWidget); + + // The list he checks against the shelf: five names, five bags, paired. + for (final pair in const [ + ('Joe', 'Bag 1'), + ('Arun', 'Bag 2'), + ('Priya', 'Bag 3'), + ('Kumar', 'Bag 4'), + ('Ravi', 'Bag 5'), + ]) { + expect(find.text(pair.$1), findsOneWidget); + expect(find.text(pair.$2), findsOneWidget); + } + }); + + testWidgets('the pickup confirmation tells him to count them first', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pump( + tester, + () => StopActionSheet( + stage: StopStatus.arrived, + placeName: 'Vidhya Kitchen', + count: fiveOrders.length, + lines: BagManifest.forGroup(fiveOrders), + onConfirm: (_) async => null, + ), + ); + + expect(find.text('Picked up from Vidhya Kitchen'), findsOneWidget); + expect( + find.text('Make sure all 5 bags are loaded before continuing.'), + findsOneWidget, + ); + // Both rungs are the same interaction, named for what they claim. + expect(find.textContaining('Slide to confirm'), findsOneWidget); + }); + + testWidgets('a card says which bag it is carrying', (tester) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pump( + tester, + () => TripCard( + trip: Trip.fromStops(id: 't', stops: fiveOrders), + acceptedIds: const {'1', '2', '3', '4', '5'}, + onToggleSelect: (_) {}, + ).buildStops(), + ); + + // Five near-identical lunch bags is where mis-delivery comes from, so + // every card names the one it is for. + expect(find.text('Bag 1'), findsOneWidget); + expect(find.text('Bag 5'), findsOneWidget); + expect(find.text('Ravi'), findsWidgets); + }); + }); +} diff --git a/test/page_routing_test.dart b/test/page_routing_test.dart new file mode 100644 index 0000000..23bea0e --- /dev/null +++ b/test/page_routing_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/service_profile.dart'; + +/// Which of the three tabs a stop belongs on, at each rung of the ladder. +/// +/// Home = decide and go · Deliveries = the work in hand · Activity = history. +/// The pages all read the same two predicates, so this is the whole contract. +String pageFor(StopStatus s) { + if (s.isWorkComplete || s.isSkipped) return 'Activity'; + if (s.isPending) return 'Home'; + return 'Deliveries'; +} + +void main() { + endOfDay(); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('milk man — collect at a kitchen, then a round of drops', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + test('picked up moves it off Home and onto Deliveries', () { + expect(pageFor(StopStatus.assigned), 'Home'); + expect(pageFor(StopStatus.accepted), 'Deliveries'); + expect(pageFor(StopStatus.picked), 'Deliveries'); + expect(pageFor(StopStatus.outForDelivery), 'Deliveries'); + expect(pageFor(StopStatus.deliveryArrived), 'Deliveries'); + }); + + test('delivered moves it to Activity', () { + expect(pageFor(StopStatus.delivered), 'Activity'); + expect(pageFor(StopStatus.skipped), 'Activity'); + }); + }); + + group('logistics — first mile, and the hub delivers it', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + // ── This is the line that does not match the request ── + // + // A logistics rider collects at the customer's door and carries the + // shipment to the hub; somebody else delivers it, on another day, and this + // app never sees that. So `picked` IS the end of his work, and there is no + // `delivered` event for him to reach. + // + // Making `picked` land on Deliveries instead would need a rung that does + // not exist yet — a hand-over-at-hub step — or every collected parcel would + // sit on that tab forever and never reach Activity. + test('picked up is the end of the rider work, so it files to Activity', () { + expect(pageFor(StopStatus.assigned), 'Home'); + expect(pageFor(StopStatus.accepted), 'Deliveries'); + expect(pageFor(StopStatus.arrived), 'Deliveries'); + expect(pageFor(StopStatus.picked), 'Activity'); + }); + + test('the profile says so explicitly', () { + expect(ServiceProfile.active.deliversToCustomer, isFalse); + expect(ServiceProfile.milkMan.deliversToCustomer, isTrue); + }); + }); +} + +/// ───────────────────────────────────────────────────────────────────────── +/// WHERE THE DAY ENDS +/// +/// A logistics rider collects at customers' doors and carries the shipments to +/// the hub, so his last leg is a building. A milk-man round is the other way +/// up: he loads at the kitchen and his last address is a customer's door, so +/// there is nothing to return and nowhere to return it to. +/// +/// The screens read [ServiceProfile.endsAtHub], never the line. +/// ───────────────────────────────────────────────────────────────────────── +void endOfDay() { + group('end of day', () { + test('a milk run finishes where it stands', () { + expect(ServiceProfile.milkMan.endsAtHub, isFalse); + expect(ServiceProfile.milkMan.deliversToCustomer, isTrue); + }); + + test('a parcel route goes back to the depot', () { + expect(ServiceProfile.parcel.endsAtHub, isTrue); + expect(ServiceProfile.parcel.deliversToCustomer, isFalse); + }); + + test('the two are one fact, not two flags that can disagree', () { + for (final p in [ + ServiceProfile.parcel, + ServiceProfile.milkMan, + ServiceProfile.delivery, + ]) { + expect(p.endsAtHub, isNot(p.deliversToCustomer)); + } + }); + }); +} diff --git a/test/picked_not_active_test.dart b/test/picked_not_active_test.dart new file mode 100644 index 0000000..a3bc34e --- /dev/null +++ b/test/picked_not_active_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PICKED IS A RUNG, NOT A MOMENT +/// +/// The regression: tapping **Picked** put the stop straight onto the +/// delivery-active rung, so the state the rider had just created was never +/// shown to him. +/// +/// The cause was a true fact read as the wrong one. `pickup-complete` releases +/// hyperlocal work itself — pickup and delivery pincodes sharing a 3-digit +/// prefix means the parcel never sees a hub — so the consignment is stamped +/// `Out_for_Delivery` in the same call that records the collection. Every +/// DailyGrubs order is hyperlocal, so the very next poll after a successful +/// Picked reported `Out_for_Delivery`, and the ladder read that as "the rider +/// is riding his round". +/// +/// It is not. It says the consignment *may be delivered*. Whether the rider has +/// set off is something only the app knows, and it knows it at the moment he +/// opens the stop. +/// +/// These tests hold the two apart. Nothing here asserts on a fabricated status: +/// the backend value under test is the one the contract says `pickup-complete` +/// writes. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// A stop as the queue hands it over, with `orderstatus` already translated + /// out of the booking enum the way `pickupFromBooking` does it. + Map stop(String backendStatus) => { + 'orderid': '1001', + 'pickupid': 1001, + 'sourcename': 'Vidhya Kitchen', + 'orderstatus': ApiConfig.legacyStatusFromNew(backendStatus), + 'pickuplat': 11.01, + 'pickuplon': 76.95, + 'droplat': 11.05, + 'droplon': 76.99, + }; + + group('the rung after a successful Picked', () { + test('a released consignment the rider has not started is PICKED', () { + // The exact state one poll after the slide: booking converted, + // consignment already out for delivery, rider still at the counter. + expect( + MilkRun.stageOf( + stop('Out_for_Delivery'), + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: const {}, + ), + StopStatus.picked, + reason: + 'Out_for_Delivery is the consignment being releasable, not the ' + 'rider having set off', + ); + }); + + test('Converted_To_Consignment is PICKED too', () { + // The non-hyperlocal half of the same call. Both endings of + // `pickup-complete` have to land on the same rung, or the word on the + // card would depend on the customer's pincode. + expect( + MilkRun.stageOf( + stop('Converted_To_Consignment'), + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + ), + StopStatus.picked, + ); + }); + + test('ACTIVE needs the rider to have opened the stop', () { + expect( + MilkRun.stageOf( + stop('Out_for_Delivery'), + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: {'1001'}, + ), + StopStatus.outForDelivery, + ); + }); + + test('the whole ladder still runs in order', () { + // Pending → Picked → Active → Delivered, end to end, with the backend + // saying `Out_for_Delivery` from the second rung onward — which is what + // it really does. + final s = stop('Pickup_Scheduled'); + expect( + MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: const {}), + StopStatus.accepted, + ); + + final picked = stop('Out_for_Delivery'); + expect( + MilkRun.stageOf(picked, acceptedIds: {'1001'}, collectedIds: {'1001'}), + StopStatus.picked, + ); + expect( + MilkRun.stageOf( + picked, + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: {'1001'}, + ), + StopStatus.outForDelivery, + ); + expect( + MilkRun.stageOf( + picked, + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: {'1001'}, + deliveredIds: {'1001'}, + ), + StopStatus.delivered, + ); + }); + }); + + group('states the backend genuinely owns still win', () { + test('a delivered stop is never dragged back to picked', () { + expect( + MilkRun.stageOf( + stop('Delivered'), + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: const {}, + ), + StopStatus.delivered, + ); + }); + + test('an arrival at the door outranks the release', () { + final s = stop('Out_for_Delivery')..['orderstatus'] = 'deliveryarrived'; + expect( + MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: {'1001'}), + StopStatus.deliveryArrived, + ); + }); + }); + + group('after a reload', () { + test('a released consignment is carried work, never live work', () { + // The collected set lives in prefs and a reinstall empties it. Before, + // `outfordelivery` matched nothing in `stopStateOf` and the row fell + // through to `pending` — putting a bag already in the rider's box back on + // Home as work to accept. + final state = stopStateOf( + stop('Out_for_Delivery'), + acceptedIds: const {}, + rejectedIds: const {}, + collectedIds: const {}, + ); + expect(state, StopState.collected); + expect(state, isNot(StopState.active)); + expect(state, isNot(StopState.pending)); + }); + + test('a started round survives, because the marker is on the device', () { + // The rider opened the stop before the app was killed. `startPickup` + // wrote the id, prefs kept it, and the rung comes back ACTIVE — which is + // the one case where the UI may say so. + expect( + MilkRun.stageOf( + stop('Out_for_Delivery'), + acceptedIds: const {}, + collectedIds: const {}, + outForDeliveryIds: {'1001'}, + ), + StopStatus.outForDelivery, + ); + }); + }); +} diff --git a/test/pickup_delivery_boundary_test.dart b/test/pickup_delivery_boundary_test.dart new file mode 100644 index 0000000..cf52a58 --- /dev/null +++ b/test/pickup_delivery_boundary_test.dart @@ -0,0 +1,235 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_domain.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE BOUNDARY BETWEEN THE TWO WORK SCREENS +/// +/// ``` +/// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED +/// └────────────────── HOME owns all of this ───────────────┘ │ +/// ▼ +/// DELIVERIES owns from here on +/// ``` +/// +/// The defect these pin: an **accepted** booking was claimed by both screens. +/// It stayed on Home to be collected and it also appeared on Deliveries as live +/// work, with a strip offering to continue it — which opened a map, an I'VE +/// ARRIVED and a confirmation sheet for a collection the rider had not made. +/// Two screens, two filters, one fact, and they disagreed. +/// +/// So the invariants are asserted on [WorkBoundary] itself rather than by +/// pumping two widgets and comparing what they drew: it is the single rule both +/// screens now read, and a rule that cannot be re-expressed per screen is the +/// actual fix. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// One order in the shape the booking adapter produces: the backend has no + /// per-stop type, so every row calls itself a pickup whatever leg it is on. + Map order( + String id, { + required String status, + String kitchen = 'Vidhya Kitchen', + }) => { + 'orderid': id, + 'pickupid': id, + 'type': 'pickup', + 'orderstatus': status, + 'kitchenname': kitchen, + 'pickupcustomer': kitchen, + 'dropcustomer': 'Customer $id', + }; + + group('a kitchen line — the boundary is pickup-complete', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + test('accepted is not picked up, and never leaves Home', () { + // The whole bug in one assertion. Accepting is a decision about work + // still to be done; nothing has changed hands. + final accepted = order('A1', status: 'accepted'); + + expect( + WorkBoundary.pickupComplete(accepted, collectedIds: const {}), + isFalse, + ); + expect( + WorkBoundary.domainOf( + accepted, + acceptedIds: const {'A1'}, + collectedIds: const {}, + ), + WorkDomain.pickup, + reason: 'an acceptance must never be read as a completed pickup', + ); + }); + + test('every rung before pickup-complete belongs to Home', () { + for (final status in ['assigned', 'accepted', 'active', 'arrived']) { + expect( + WorkBoundary.domainOf( + order('A1', status: status), + acceptedIds: const {'A1'}, + ), + WorkDomain.pickup, + reason: status, + ); + } + }); + + test('the backend saying picked is what moves it', () { + // Both spellings the contract produces: `Picked_Up` and the conversion + // to a consignment. + for (final status in ['picked', 'Picked up']) { + expect( + WorkBoundary.domainOf(order('A1', status: status)), + WorkDomain.delivery, + reason: status, + ); + } + }); + + test('a confirmed pickup keeps it on Deliveries through a stale poll', () { + // The queue is up to one poll behind, and it answers with what it knew + // before the hand-over. Without the collected record the order would + // bounce back to Home for a few seconds, which reads as the confirm + // having failed — so the record wins, and it is written only after the + // backend confirms. See `_advanceStop` on Home. + final stale = order('A1', status: 'accepted'); + + expect( + WorkBoundary.domainOf(stale, collectedIds: const {'A1'}), + WorkDomain.delivery, + ); + }); + + test('the delivery rungs stay on Deliveries', () { + for (final status in ['out_for_delivery', 'delivery_arrived']) { + expect( + WorkBoundary.domainOf(order('A1', status: status)), + WorkDomain.delivery, + reason: status, + ); + } + }); + + test('delivered and cancelled leave both work screens', () { + expect( + WorkBoundary.domainOf(order('A1', status: 'delivered')), + WorkDomain.closed, + ); + expect( + WorkBoundary.domainOf(order('A1', status: 'cancelled')), + WorkDomain.closed, + ); + }); + + test('a declined stop stays on Home — it is not delivery work', () { + // It stays visible there precisely so the rider can change his mind. + expect( + WorkBoundary.domainOf(order('A1', status: 'rejected')), + WorkDomain.pickup, + ); + }); + + test('picked is the middle of the morning, not the end of it', () { + // The line does not end at a hub: he is holding the food and still owes + // every door. Closing it here is what once sent a collected crate to + // Activity as fifteen finished deliveries. + expect(WorkBoundary.isClosed(order('A1', status: 'picked')), isFalse); + }); + }); + + group('one kitchen, partially accepted, partially collected', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + final day = [ + order('A1', status: 'picked'), // collected at the counter + order('A2', status: 'accepted'), // taken, still on the counter + order('A3', status: 'assigned'), // not decided yet + order('A4', status: 'delivered'), // done + ]; + + test('the two screens split the day, with nothing shared', () { + final home = WorkBoundary.pickupQueue(day, acceptedIds: const {'A2'}); + final deliveries = WorkBoundary.deliveryQueue( + day, + acceptedIds: const {'A2'}, + ); + + expect(home.map(MilkRun.idOf), ['A2', 'A3']); + expect(deliveries.map(MilkRun.idOf), ['A1']); + + // The invariant that makes the pill trustworthy: no order can be counted + // by both screens, and none can fall between them. + final overlap = home + .map(MilkRun.idOf) + .toSet() + .intersection(deliveries.map(MilkRun.idOf).toSet()); + expect(overlap, isEmpty); + expect( + home.length + deliveries.length, + day.length - 1, + reason: 'A4 closed', + ); + }); + + test("Home's pill counts the other tab's rows, not its own", () { + // The pill's tap goes to Deliveries, so its number has to be that tab's + // — asked of the same rule, from the same day. It used to count accepted + // work, so it read 3 while the tab it opened listed 1. + expect( + WorkBoundary.deliveryQueue(day, acceptedIds: const {'A2'}).length, + 1, + ); + }); + + test('accepting the rest moves nothing across', () { + final deliveries = WorkBoundary.deliveryQueue( + day, + acceptedIds: const {'A2', 'A3'}, + ); + expect(deliveries.map(MilkRun.idOf), ['A1']); + }); + }); + + group('logistics — the boundary is acceptance, and that is unchanged', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + test("an accepted booking is the work tab's, as it always was", () { + // There is no counter and nothing to batch on this line: the rider drives + // to one customer and raises the shipment at the door, so the collection + // *is* the job and it is worked from that tab. The rule is the same one, + // read through [ServiceProfile.handoffAt]. + expect( + WorkBoundary.domainOf( + order('P1', status: 'accepted'), + acceptedIds: const {'P1'}, + ), + WorkDomain.delivery, + ); + }); + + test('an undecided booking is still Home\'s', () { + expect( + WorkBoundary.domainOf(order('P1', status: 'assigned')), + WorkDomain.pickup, + ); + }); + + test('collected ends the job on a line that ends at the hub', () { + // Terminal here, unlike on the milk run: the parcel becomes a consignment + // and the hub carries it from there. + expect(WorkBoundary.isClosed(order('P1', status: 'picked')), isTrue); + expect( + WorkBoundary.domainOf(order('P1', status: 'picked')), + WorkDomain.closed, + ); + }); + }); +} diff --git a/test/profile_pages_test.dart b/test/profile_pages_test.dart index d730a2f..a5cb380 100644 --- a/test/profile_pages_test.dart +++ b/test/profile_pages_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,8 +41,9 @@ void main() { designSize: const Size(390, 844), builder: (_, _) => MaterialApp( builder: (context, child) => MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), child: child!, ), home: page, @@ -52,39 +54,59 @@ void main() { } group('the shared pushed-page bar', () { - testWidgets('is a glass surface with a slate title', (tester) async { + // ── The wash gave way to the brand, everywhere at once ── + // + // This bar wore a frosted 7% brand wash flattened against white while Home + // and the four tabs are solid brand. So pushing Edit Profile, Saved + // Addresses, Alert Sound or Notifications changed the colour of the top of + // the phone AND the colour of the status-bar glyphs — which reads as + // leaving the app rather than opening a page inside it. + // + // One ground now, and everything on it inverts. The contract this test + // still holds is the one that matters: whatever the bar's surface is, the + // type on it has to clear contrast against it, and every bar in the app has + // to agree on the answer. + testWidgets('is solid brand with a white title', (tester) async { await pump( tester, Scaffold(appBar: milerPageBar('Saved Addresses', onBack: () {})), ); - // The bar is transparent and wears `milerGlassSurface` — a frosted 7% - // brand wash — rather than a solid slab of either white or red. The rule - // it is holding to is unchanged: the surface stays light enough for - // near-black type, so the loudest colour in the palette is not spent on - // the one element that never changes. final bar = tester.widget(find.byType(AppBar)); - expect(bar.backgroundColor, Colors.transparent); - expect(bar.flexibleSpace, isNotNull, - reason: 'the glass wash is what colours the bar now'); + expect(bar.backgroundColor, ColorConstants.primary); + expect( + bar.flexibleSpace, + isNull, + reason: 'a solid ground needs no surface layered over it', + ); + expect( + bar.systemOverlayStyle?.statusBarIconBrightness, + Brightness.light, + reason: + 'dark glyphs on maroon is the one contrast failure a rider ' + 'cannot work around by tilting the phone', + ); - // ── Slate, not maroon ── - // - // This was the only heading in the app in the brand colour, so an Account - // page's title read as a link while the same heading on a tab was - // near-black. One title colour across every bar; the brand still owns the - // glass wash behind it. final title = tester.widget(find.text('Saved Addresses')); - expect(title.style!.color, ColorConstants.slateText); - expect(title.style!.fontSize, MilerType.pageTitleFixed.fontSize, - reason: 'one page-title size across tabs and pushed screens'); + expect(title.style!.color, Colors.white); + expect( + title.style!.fontSize, + MilerType.pageTitleFixed.fontSize, + reason: 'one page-title size across tabs and pushed screens', + ); + + // The back control inverts with it, or it disappears into the ground. + final back = tester.widget(find.byIcon(LucideIcons.arrowLeft)); + expect(back.color, Colors.white); }); testWidgets('carries a back affordance when given one', (tester) async { var popped = false; await pump( tester, - Scaffold(appBar: milerPageBar('Notifications', onBack: () => popped = true)), + Scaffold( + appBar: milerPageBar('Notifications', onBack: () => popped = true), + ), ); // ── An arrow, because these pages are pushed ── @@ -92,18 +114,18 @@ void main() { // The cross promised dismissal on nine screens you actually go *back* // from, and it was one of three back glyphs in the app. `close_rounded` // survives behind `isModal: true`, for a sheet that genuinely dismisses. - expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget); - expect(find.byIcon(Icons.close_rounded), findsNothing); + expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget); + expect(find.byIcon(LucideIcons.x), findsNothing); - await tester.tap(find.byIcon(Icons.arrow_back_rounded)); + await tester.tap(find.byIcon(LucideIcons.arrowLeft)); await tester.pumpAndSettle(); expect(popped, isTrue); }); testWidgets('and none when it is not', (tester) async { await pump(tester, Scaffold(appBar: milerPageBar('Terms'))); - expect(find.byIcon(Icons.arrow_back_rounded), findsNothing); - expect(find.byIcon(Icons.close_rounded), findsNothing); + expect(find.byIcon(LucideIcons.arrowLeft), findsNothing); + expect(find.byIcon(LucideIcons.x), findsNothing); }); testWidgets('a modal presentation still gets a cross', (tester) async { @@ -113,8 +135,8 @@ void main() { appBar: milerPageBar('Skip stop', onBack: () {}, isModal: true), ), ); - expect(find.byIcon(Icons.close_rounded), findsOneWidget); - expect(find.byIcon(Icons.arrow_back_rounded), findsNothing); + expect(find.byIcon(LucideIcons.x), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowLeft), findsNothing); }); testWidgets('survives a long title and a large font', (tester) async { @@ -158,7 +180,7 @@ void main() { Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})), ); - final close = tester.getRect(find.byIcon(Icons.arrow_back_rounded)); + final close = tester.getRect(find.byIcon(LucideIcons.arrowLeft)); final title = tester.getRect(find.text('Edit Profile')); expect( @@ -181,10 +203,12 @@ void main() { Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})), ); final box = tester.getSize( - find.ancestor( - of: find.byIcon(Icons.arrow_back_rounded), - matching: find.byType(SizedBox), - ).first, + find + .ancestor( + of: find.byIcon(LucideIcons.arrowLeft), + matching: find.byType(SizedBox), + ) + .first, ); // 48, not the 44pt accessibility floor: this is the control a rider hits // one-handed, and the glyph inside stays 24 so only the target grew. @@ -201,19 +225,25 @@ void main() { for (final width in [320.0, 360.0, 390.0, 430.0]) { for (final scale in [1.0, 1.3, 1.5, 2.0]) { testWidgets('at ${width.toInt()}pt / ${scale}x', (tester) async { - await pump(tester, const FaqPage(), - width: width, textScale: scale); - expect(tester.takeException(), isNull, - reason: 'FAQ overflowed at ${width.toInt()}pt / ${scale}x'); + await pump(tester, const FaqPage(), width: width, textScale: scale); + expect( + tester.takeException(), + isNull, + reason: 'FAQ overflowed at ${width.toInt()}pt / ${scale}x', + ); // Opening the accordions is where the page grows the most. final tiles = find.byType(ExpansionTile); for (var i = 0; i < tiles.evaluate().length; i++) { await tester.tap(tiles.at(i), warnIfMissed: false); await tester.pumpAndSettle(); - expect(tester.takeException(), isNull, - reason: 'FAQ overflowed opening accordion $i at ' - '${width.toInt()}pt / ${scale}x'); + expect( + tester.takeException(), + isNull, + reason: + 'FAQ overflowed opening accordion $i at ' + '${width.toInt()}pt / ${scale}x', + ); } }); } diff --git a/test/provider_dedupe_test.dart b/test/provider_dedupe_test.dart new file mode 100644 index 0000000..69d0aa3 --- /dev/null +++ b/test/provider_dedupe_test.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_repository.dart'; +import 'package:miler/providers/pickup/pickup_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// FOUR SCREENS, ONE REQUEST +/// +/// Home, Bookings, Activity and the post-delivery screen all read the day, and +/// each produced its own `GET /miler/bookings` — on mount, on tab switch, and +/// on every poll. Four timers, four copies, four ideas of what had been +/// accepted, reconciled only by whichever refreshed last. +/// +/// The provider still returns a list or throws, which is what every call site +/// was written against. What changed is that they now share one request. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late int requests; + final provider = PickupProvider(); + + void stub({List> rows = const [], int code = 200}) { + requests = 0; + MilerApi.client = MockClient((req) async { + // The repository also reads `/miler/assignments` for the hub's stop + // sequence; it is not the day's request and is not counted. See the same + // note in `work_repository_test.dart`. + if (req.url.path.endsWith('/miler/assignments')) { + return http.Response( + json.encode({'success': true, 'data': const []}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + requests++; + return http.Response( + json.encode({'success': code < 400, 'data': rows}), + code, + headers: const {'content-type': 'application/json'}, + ); + }); + } + + Map booking(String id) => { + 'bookingid': id, + 'orderid': id, + 'pickupcustomer': 'Stop $id', + 'pickupaddress': '12 Main Rd', + 'status': 'Miler_Assigned', + }; + + setUp(() async { + MockBackend.enabled = false; + ServiceProfile.setActive(ServiceProfile.parcel); + SharedPreferences.setMockInitialValues({'authtoken': 't'}); + await ApiConfig.setToken('t'); + WorkRepository.instance.resetForTest(); + stub(rows: [booking('A')]); + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + ServiceProfile.setActive(ServiceProfile.parcel); + WorkRepository.instance.resetForTest(); + }); + + test('the three screen entry points share one request', () async { + final results = await Future.wait([ + provider.getPickupQueues(), + provider.getCurrentPickups(), + provider.getPickupQueuesPicked(), + ]); + + expect(requests, 1, reason: 'three screens, one GET /miler/bookings'); + for (final r in results) { + expect(r, hasLength(1)); + } + }); + + test('a tab switch moments later does not re-fetch', () async { + await provider.getCurrentPickups(); + await provider.getPickupQueuesPicked(); + expect(requests, 1); + }); + + test( + 'every caller gets the same objects, so no screen has its own copy', + () async { + final a = await provider.getCurrentPickups(); + final b = await provider.getPickupQueuesPicked(); + expect(identical(a, b), isTrue); + }, + ); + + test('an empty day is an empty list, not an error', () async { + stub(rows: const []); + expect(await provider.getCurrentPickups(), isEmpty); + }); + + // ── The milk-man line is dispatched, not written off ── + // + // This expected a [LineNotServedException] after zero requests, on the belief + // that the backend had no endpoint for that line. It has the same one: the + // console assigns the round on /admin/bookings and the rider reads it back + // from /miler/bookings. While the two disagreed, a rider on tenant 13 with a + // booking assigned to him got an empty screen and no error to explain it. + test('a milk-man rider is asked for, not refused', () async { + stub(rows: [booking('MILK-1')]); + ServiceProfile.setActive(ServiceProfile.milkMan); + WorkRepository.instance.resetForTest(); + + final day = await provider.getCurrentPickups(); + expect(day, hasLength(1)); + expect(requests, 1, reason: 'his work has to be asked for to arrive'); + }); + + test('a server failure throws rather than reading as an empty day', () async { + stub(code: 500); + await expectLater(provider.getCurrentPickups(), throwsA(isA())); + }); + + // A filtered read is a different question and must not be served the shared + // copy of the whole day. + test('a status-filtered read gets its own request', () async { + await provider.getCurrentPickups(); + await provider.getPickupQueues(orderstatus: 'Picked_Up'); + expect(requests, 2); + }); +} diff --git a/test/review_grid_test.dart b/test/review_grid_test.dart index b29717d..21415e2 100644 --- a/test/review_grid_test.dart +++ b/test/review_grid_test.dart @@ -18,8 +18,9 @@ void main() { ReviewFact wide(String label, String value) => ReviewFact(label, value, icon: Icons.circle, wide: true); - List> labels(List> rows) => - [for (final r in rows) [for (final f in r) f.label]]; + List> labels(List> rows) => [ + for (final r in rows) [for (final f in r) f.label], + ]; test('two short facts share a row', () { final rows = packReviewFacts([ diff --git a/test/route_brief_test.dart b/test/route_brief_test.dart index 41634bf..6947199 100644 --- a/test/route_brief_test.dart +++ b/test/route_brief_test.dart @@ -93,19 +93,21 @@ void main() { expect(withOrigin.routeKm, lessThan(15)); }); - test('falls back to the per-stop kms field when coordinates are missing', - () { - final brief = RouteBrief.from( - orderIds: const ['A'], - ordersMap: { - 'A': {'Quantity': '1', 'kms': '4.2'}, - }, - shiftStart: '', - shiftEnd: '', - ); + test( + 'falls back to the per-stop kms field when coordinates are missing', + () { + final brief = RouteBrief.from( + orderIds: const ['A'], + ordersMap: { + 'A': {'Quantity': '1', 'kms': '4.2'}, + }, + shiftStart: '', + shiftEnd: '', + ); - expect(brief.routeKm, 4.2); - }); + expect(brief.routeKm, 4.2); + }, + ); }); group('RouteBrief shift window', () { @@ -267,16 +269,19 @@ void main() { orderIds: const ['late', 'soon', 'later'], ordersMap: { 'late': { - 'expected_pickup_time': - now.subtract(const Duration(minutes: 20)).toIso8601String(), + 'expected_pickup_time': now + .subtract(const Duration(minutes: 20)) + .toIso8601String(), }, 'soon': { - 'expected_pickup_time': - now.add(const Duration(minutes: 10)).toIso8601String(), + 'expected_pickup_time': now + .add(const Duration(minutes: 10)) + .toIso8601String(), }, 'later': { - 'expected_pickup_time': - now.add(const Duration(hours: 3)).toIso8601String(), + 'expected_pickup_time': now + .add(const Duration(hours: 3)) + .toIso8601String(), }, }, shiftStart: '09:00:00', diff --git a/test/route_flow_test.dart b/test/route_flow_test.dart index 9551407..b55b760 100644 --- a/test/route_flow_test.dart +++ b/test/route_flow_test.dart @@ -152,8 +152,10 @@ void main() { test('t outside 0..1 is clamped rather than extrapolated', () { expect(routeFlowFrame(-1, growShare: grow, holdShare: hold).progress, 0); - expect(routeFlowFrame(2, growShare: grow, holdShare: hold).phase, - RouteFlowPhase.fade); + expect( + routeFlowFrame(2, growShare: grow, holdShare: hold).phase, + RouteFlowPhase.fade, + ); expect(routeFlowFadeAlpha(-1, 0.5), 1); expect(routeFlowFadeAlpha(9, 0.5), 0); }); diff --git a/test/route_metrics_test.dart b/test/route_metrics_test.dart new file mode 100644 index 0000000..a873749 --- /dev/null +++ b/test/route_metrics_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; + +/// ── `metersToStop` measures the leg the caller is on ── +/// +/// It always read the pickup pair, which on the delivery leg measured the +/// ride BACK to the kitchen the rider had just left — the card's distance +/// grew as he approached the customer's door. The leg now arrives as a flag, +/// and these pin the three behaviours that keep both legs honest. +void main() { + // Roughly Coimbatore: kitchen at RS Puram, door ~4.7 km northeast. + const riderLat = 11.0080, riderLng = 76.9500; // standing at the kitchen + const stop = { + 'pickuplat': 11.0080, + 'pickuplon': 76.9502, // ~20 m from the rider + 'droplat': 11.0183, + 'droplon': 76.9678, // ~2.2 km away + 'kms': '9.9', + }; + + test('the default is the pickup pair — Home is untouched', () { + final m = RouteMetricsHelper.metersToStop( + stop, + riderLat: riderLat, + riderLng: riderLng, + ); + expect(m, isNotNull); + expect(m!, lessThan(200), reason: 'he is standing at the pickup'); + }); + + test('toDrop measures to the door', () { + final m = RouteMetricsHelper.metersToStop( + stop, + riderLat: riderLat, + riderLng: riderLng, + toDrop: true, + ); + expect(m, isNotNull); + expect( + m!, + greaterThan(1500), + reason: 'the delivery leg is the ride ahead, not the counter behind', + ); + }); + + test('a payload with no drop pair falls back to the pickup pair', () { + // On many hyperlocal payloads the stop IS the customer's address; a + // wrong-ish number beats a dash the rider cannot plan with. + final m = RouteMetricsHelper.metersToStop( + {'pickuplat': 11.0080, 'pickuplon': 76.9502}, + riderLat: riderLat, + riderLng: riderLng, + toDrop: true, + ); + expect(m, isNotNull); + expect(m!, lessThan(200)); + }); + + test('the backend kms fallback survives the flag', () { + final m = RouteMetricsHelper.metersToStop({'kms': '9.9'}, toDrop: true); + expect(m, closeTo(9900, 0.1)); + }); +} diff --git a/test/route_order_test.dart b/test/route_order_test.dart new file mode 100644 index 0000000..1fb94d3 --- /dev/null +++ b/test/route_order_test.dart @@ -0,0 +1,353 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/route_order.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE ADMIN'S ROUTE IS THE ROUTE +/// +/// ── The regression ── +/// +/// The pickup leg followed the hub's solved `step`. The delivery leg did not, +/// and could not: `step` reached the app only through `GET /miler/assignments`, +/// which is deliberately the *active* queue (`Assigned`/`Accepted`), and a +/// booking leaves those two states the moment it is collected — which is +/// exactly when the delivery leg begins. So every delivery arrived unsequenced +/// and the tab re-sorted it **nearest-first from the rider's GPS fix**, while +/// the hub believed its route was being followed. +/// +/// Two systems, two orders, and neither side had any way to notice. That is +/// not a cosmetic difference: the sequence is what the customer's arrival +/// window was quoted from. +/// +/// These pin the rule end to end — that an assigned order survives the whole +/// pipeline, that nothing replaces it, and that the fallback is a fallback and +/// says so. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({'userid': 38}); + ServiceProfile.setActive(ServiceProfile.milkMan); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// A stop as the Deliveries tab holds it, with a position and a place. + Map stop( + String id, { + int step = 0, + double lat = 11.0, + double lng = 76.9, + String? due, + String status = 'outfordelivery', + }) => { + 'orderid': id, + 'pickupid': int.tryParse(id) ?? id.hashCode.abs(), + 'orderstatus': status, + 'step': step, + 'pickuplat': lat, + 'pickuplon': lng, + 'droplat': lat, + 'droplon': lng, + if (due != null) 'expected_pickup_time': due, + }; + + /// Straight-line-ish stand-in for the rider's distance to a stop, so the + /// proximity branch is exercised without a GPS. + double fromRider(Map s) => + ((s['droplat'] as double) - 11.0).abs() * 1000; + + List idsOf(List> stops) => [ + for (final s in stops) (s['orderid'] as String), + ]; + + group('an assigned sequence is used, exactly', () { + test('stops come back in the hub\'s order, not the list\'s', () { + final (ordered, source) = RouteOrder.sort([ + stop('3', step: 3), + stop('1', step: 1), + stop('2', step: 2), + ]); + expect(idsOf(ordered), ['1', '2', '3']); + expect(source, RouteOrderSource.adminSequence); + }); + + test('nearest-first never replaces an assigned order', () { + // Stop 3 is on the rider's doorstep and stop 1 is furthest away. The old + // Deliveries sort would have put 3 first. The hub said 1. + final (ordered, source) = RouteOrder.sort([ + stop('1', step: 1, lat: 11.9), + stop('2', step: 2, lat: 11.5), + stop('3', step: 3, lat: 11.0), + ], distanceTo: fromRider); + + expect(idsOf(ordered), ['1', '2', '3']); + expect( + source, + RouteOrderSource.adminSequence, + reason: + 'offering a distance function must not tempt the sort into ' + 'using it while a route exists', + ); + }); + + test('a booked time never replaces an assigned order either', () { + final (ordered, _) = RouteOrder.sort( + [ + stop('1', step: 1, due: '2026-08-21T18:00:00'), + stop('2', step: 2, due: '2026-08-21T09:00:00'), + ], + bookedTimeOf: (s) => DateTime.tryParse('${s['expected_pickup_time']}'), + ); + expect(idsOf(ordered), ['1', '2']); + }); + + test('one sequenced stop makes the whole set admin-ordered', () { + // The old sort split the list — sequenced stops by step, the rest by + // distance — and produced a list that was neither order. + final (ordered, source) = RouteOrder.sort([ + stop('far', lat: 11.9), + stop('near', lat: 11.0), + stop('assigned', step: 1, lat: 11.5), + ], distanceTo: fromRider); + + expect(source, RouteOrderSource.adminSequence); + expect( + idsOf(ordered).first, + 'assigned', + reason: 'sequenced work leads the route the hub planned', + ); + expect( + idsOf(ordered).sublist(1), + ['far', 'near'], + reason: + 'the unplanned tail keeps the order it arrived in — it is not ' + 're-sorted by distance behind a solved route', + ); + }); + + test('step 0 is "not sequenced", never "first"', () { + expect(RouteOrder.sequenceOf(stop('a')), 0); + expect(RouteOrder.sequenceOf(stop('a', step: 1)), 1); + expect(RouteOrder.hasAdminSequence([stop('a'), stop('b')]), isFalse); + expect( + RouteOrder.hasAdminSequence([stop('a'), stop('b', step: 4)]), + isTrue, + ); + }); + + test('the delivery leg\'s own spelling is read too', () { + expect(RouteOrder.sequenceOf({'deliverystep': 2}), 2); + expect(RouteOrder.sequenceOf({'routestep': 5}), 5); + expect(RouteOrder.sequenceOf({'sequence': '7'}), 7); + }); + }); + + group('the fallback is a fallback', () { + test('nearest-first is used only with no assigned sequence at all', () { + final (ordered, source) = RouteOrder.sort([ + stop('far', lat: 11.9), + stop('near', lat: 11.0), + stop('mid', lat: 11.5), + ], distanceTo: fromRider); + + expect(idsOf(ordered), ['near', 'mid', 'far']); + expect(source, RouteOrderSource.proximity); + expect( + source.isAdmin, + isFalse, + reason: + 'the screen must be able to tell the rider this is the app\'s ' + 'guess and not his route', + ); + }); + + test('booked time is preferred over the rider\'s position', () { + final (ordered, source) = RouteOrder.sort( + [ + stop('later', lat: 11.0, due: '2026-08-21T18:00:00'), + stop('sooner', lat: 11.9, due: '2026-08-21T09:00:00'), + ], + distanceTo: fromRider, + bookedTimeOf: (s) => DateTime.tryParse('${s['expected_pickup_time']}'), + ); + expect(idsOf(ordered), ['sooner', 'later']); + expect(source, RouteOrderSource.bookedTime); + }); + + test('with nothing to go on, the backend order is kept', () { + final (ordered, source) = RouteOrder.sort([stop('b'), stop('a')]); + expect(idsOf(ordered), [ + 'b', + 'a', + ], reason: 'unordered means unchanged, not shuffled'); + expect(source, RouteOrderSource.backendOrder); + }); + + test('no GPS is not a reason to invent an order', () { + final (ordered, source) = RouteOrder.sort([ + stop('b', lat: 11.9), + stop('a', lat: 11.0), + ]); + expect(idsOf(ordered), ['b', 'a']); + expect(source, RouteOrderSource.backendOrder); + }); + + test('each source says out loud what it is', () { + expect(RouteOrderSource.adminSequence.label, 'Hub route'); + expect(RouteOrderSource.proximity.label, 'Nearest first'); + expect( + RouteOrderSource.proximity.explanation, + contains('No route assigned'), + ); + expect(RouteOrderSource.adminSequence.explanation, contains('assigned')); + }); + }); + + group('API → repository → screen', () { + test('the sequence survives the booking adapter', () { + // The adapter builds a FIXED map: a field it does not name is a field no + // screen can ever see. `step` was not named, so the hub's order was + // dropped in translation — before any sort had a chance to honour it. + final stop = ApiConfig.pickupFromBooking({ + 'bookingid': 75, + 'bookingreference': 'DM-BK-1', + 'status': 'Converted_To_Consignment', + 'consignmentid': 46, + 'consignmentstatus': 'Out_for_Delivery', + 'step': 4, + 'stoptype': 'delivery', + }); + + expect(RouteOrder.sequenceOf(stop), 4); + expect( + stop['type'], + 'delivery', + reason: 'the leg was hardcoded to "pickup" for every row', + ); + }); + + test('an unsequenced booking maps to 0, not to null or first', () { + final stop = ApiConfig.pickupFromBooking({ + 'bookingid': 82, + 'status': 'Miler_Assigned', + 'step': 0, + }); + expect(RouteOrder.sequenceOf(stop), 0); + }); + + test('both legs order the same day identically', () { + // Home orders through Trip.sortStops, Deliveries through RouteOrder. + // They were two implementations; a stop third on one was second on the + // other. One rule now, and this is the assertion that keeps it one. + final day = [ + stop('c', step: 3, lat: 11.0), + stop('a', step: 1, lat: 11.9), + stop('b', step: 2, lat: 11.5), + ]; + final home = Trip.sortStops([...day]); + final (deliveries, source) = RouteOrder.sort([ + ...day, + ], distanceTo: fromRider); + expect(idsOf(home), idsOf(deliveries)); + expect(idsOf(home), ['a', 'b', 'c']); + expect(source, RouteOrderSource.adminSequence); + }); + }); + + group('the order holds as the day progresses', () { + test('a partly collected run keeps the assigned sequence', () { + // Collection changes a stop's status, not its place in the route. + final (ordered, source) = RouteOrder.sort([ + stop('1', step: 1, status: 'picked'), + stop('2', step: 2, status: 'accepted'), + stop('3', step: 3, status: 'outfordelivery'), + ], distanceTo: fromRider); + expect(idsOf(ordered), ['1', '2', '3']); + expect(source, RouteOrderSource.adminSequence); + }); + + test('a fully collected run keeps it too', () { + final (ordered, _) = RouteOrder.sort([ + stop('1', step: 1, status: 'outfordelivery', lat: 11.9), + stop('2', step: 2, status: 'outfordelivery', lat: 11.0), + ], distanceTo: fromRider); + expect( + idsOf(ordered), + ['1', '2'], + reason: + 'this is precisely where the old code switched to nearest-first', + ); + }); + }); + + group('trips group the route without re-ordering it', () { + Map at(String id, int step, DateTime due) => { + ...stop(id, step: step), + 'expected_pickup_time': due.toIso8601String(), + }; + + test('grouping into day parts never reorders within a trip', () { + final trips = Trip.groupIntoTrips([ + at('m3', 3, DateTime(2026, 8, 21, 10)), + at('m1', 1, DateTime(2026, 8, 21, 8)), + at('m2', 2, DateTime(2026, 8, 21, 9)), + ]); + expect(trips.single.tripNumber, 1); + expect(idsOf(trips.single.stops), ['m1', 'm2', 'm3']); + }); + + test('the sequence is not re-based per trip', () { + // Steps 4,5 are the hub's numbers for the afternoon leg of one solved + // route. Grouping must not renumber them, or reverse them because they + // are "later" in a list that starts at 1. + final trips = Trip.groupIntoTrips([ + at('a5', 5, DateTime(2026, 8, 21, 15)), + at('a4', 4, DateTime(2026, 8, 21, 14)), + at('m1', 1, DateTime(2026, 8, 21, 9)), + ]); + expect(trips.length, 2); + expect(idsOf(trips.first.stops), ['m1']); + expect(idsOf(trips.last.stops), ['a4', 'a5']); + expect(RouteOrder.sequenceOf(trips.last.stops.first), 4); + }); + + test('a backend trip id overrides the day-part grouping', () { + // The hub's own grouping wins over the app's clock rule, and the stops + // inside it stay in the hub's order. + final trips = Trip.groupIntoTrips([ + {...at('b', 2, DateTime(2026, 8, 21, 20)), 'tripid': 'T1'}, + {...at('a', 1, DateTime(2026, 8, 21, 9)), 'tripid': 'T1'}, + ]); + expect(trips.length, 1, reason: 'morning and evening, one hub trip'); + expect(idsOf(trips.single.stops), ['a', 'b']); + }); + + test('a slot id overrides it as well', () { + final trips = Trip.groupIntoTrips([ + {...at('a', 1, DateTime(2026, 8, 21, 9)), 'slotid': 'S9'}, + {...at('b', 2, DateTime(2026, 8, 21, 19)), 'slotid': 'S9'}, + ]); + expect(trips.length, 1); + expect(idsOf(trips.single.stops), ['a', 'b']); + }); + + test('day parts split the day but not the route', () { + final trips = Trip.groupIntoTrips([ + at('m', 1, DateTime(2026, 8, 21, 9)), + at('a', 2, DateTime(2026, 8, 21, 14)), + at('e', 3, DateTime(2026, 8, 21, 19)), + ]); + expect(trips.map((t) => t.tripNumber), [1, 2, 3]); + expect( + [for (final t in trips) ...idsOf(t.stops)], + ['m', 'a', 'e'], + reason: 'flattening the trips must give back the hub\'s sequence', + ); + }); + }); +} diff --git a/test/selection_bar_test.dart b/test/selection_bar_test.dart index 3b4f767..d48d5be 100644 --- a/test/selection_bar_test.dart +++ b/test/selection_bar_test.dart @@ -42,7 +42,7 @@ void main() { testWidgets('offers both decisions on the ticked set', (tester) async { await pump(tester, count: 3, width: 390); - expect(find.text('Accept 3'), findsOneWidget); + expect(find.text('Accept (3)'), findsOneWidget); expect(find.text('Reject'), findsOneWidget); }); @@ -81,7 +81,7 @@ void main() { testWidgets('hides reject when the page does not offer it', (tester) async { await pump(tester, count: 2, width: 390, withReject: false); expect(find.text('Reject'), findsNothing); - expect(find.text('Accept 2'), findsOneWidget); + expect(find.text('Accept (2)'), findsOneWidget); }); // Two buttons and a count on one line is exactly the shape that overflows on diff --git a/test/service_card_test.dart b/test/service_card_test.dart new file mode 100644 index 0000000..ab1996a --- /dev/null +++ b/test/service_card_test.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// P2 — what a stop card says on a service tenant. +/// +/// The three facts under test are the three a rider reads before he moves: what +/// kind of work this is, where the order has got to, and which bag out of ten in +/// his box belongs at this door. +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the work-type chip', () { + test('Doormile still names each stop kind separately', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect(StopKind.pickup.chipLabel, 'PICKUP'); + expect(StopKind.delivery.chipLabel, 'DELIVERY'); + expect(StopKind.combined.chipLabel, 'P&D'); + expect(StopKind.pickup.chipColor, ColorConstants.pickupChip); + }); + + test('a milk run wears one mark, and it is not the line’s name', () { + // ── This assertion changed with product direction ── + // + // It used to require the word MILK. That is the app's internal name for + // a *service profile* — the thing that decides which capabilities exist + // and whose records belong to whom — and it was being printed on the + // rider's cards and on his Activity record as `Type: MILK`. He did not + // choose it, cannot change it and gains nothing from reading it. + // + // What has NOT changed is the property the mark exists for: on this line + // every stop wears the *same* mark, because the rider handles a kitchen + // crate and a subscriber's doorstep identically and a chip that flipped + // between them would imply a difference in handling that does not exist. + ServiceProfile.setActive(ServiceProfile.meals); + for (final kind in StopKind.values) { + expect(kind.chipLabel, 'DELIVERY', reason: '$kind'); + expect(kind.badgeLabel, 'DELIVERY', reason: '$kind'); + expect(kind.chipColor, ColorConstants.serviceChip, reason: '$kind'); + } + // Sentence case where the type is stated as a label rather than stamped. + expect(StopKind.delivery.titleLabel, 'Delivery'); + + // And the internal name reaches no screen at all. + for (final kind in StopKind.values) { + expect(kind.chipLabel.toUpperCase(), isNot(contains('MILK'))); + expect(kind.titleLabel.toUpperCase(), isNot(contains('MILK'))); + } + }); + + test('the stop keeps its real kind underneath the one label', () { + ServiceProfile.setActive(ServiceProfile.meals); + // P3's collection gate depends on this still being true: a kitchen stop + // is a pickup and a subscriber drop is a delivery, whatever the chip says. + expect(stopKindOf({'type': 'delivery'}), StopKind.delivery); + expect(stopKindOf({'type': 'pickup'}), StopKind.pickup); + }); + }); + + group('the status tag', () { + test('a parcel route names a pending stop as a decision it awaits', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect(StopState.pending.label, 'Awaiting your decision'); + expect(StopState.done.label, 'Completed'); + }); + + test('a meal rider is told the state, not asked for a decision', () { + ServiceProfile.setActive(ServiceProfile.meals); + // He accepted the whole assignment at once — there is no per-stop + // decision left to await. + expect(StopState.pending.label, 'Pending'); + expect(StopState.active.label, 'Out for delivery'); + expect(StopState.done.label, 'Delivered'); + expect(StopState.skipped.label, 'Skipped'); + }); + }); + + group('source and bag label', () { + test('read the spellings the backend might send', () { + expect(stopSourceName({'kitchenname': 'Kitchen 1'}), 'Kitchen 1'); + expect(stopSourceName({'SourceName': ' Anna Nagar '}), 'Anna Nagar'); + expect(stopSourceId({'kitchenid': 'K-77'}), 'K-77'); + expect(stopBagLabel({'baglabel': 'DG-1042'}), 'DG-1042'); + expect(stopBagLabel({'labelcode': 'DG-9'}), 'DG-9'); + }); + + test( + 'absent on a parcel stop rather than empty-stringed onto the card', + () { + expect(stopSourceName({'orderid': '7'}), ''); + expect(stopBagLabel({'orderid': '7'}), ''); + }, + ); + }); + + group('StopSourceLine', () { + Future pump(WidgetTester tester, String source, String bag) async { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: StopSourceLine(source: source, bagLabel: bag), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('takes no space at all on a stop with neither', (tester) async { + await pump(tester, '', ''); + // A Doormile card must be exactly what it was — not what it was plus an + // empty row's padding. + final size = tester.getSize(find.byType(StopSourceLine)); + expect(size.height, 0); + }); + + testWidgets('shows both when the stop carries both', (tester) async { + await pump(tester, 'Kitchen 1', 'DG-1042'); + expect(find.text('Kitchen 1'), findsOneWidget); + expect(find.text('Bag DG-1042'), findsOneWidget); + }); + + testWidgets('shows the bag alone when there is no source', (tester) async { + await pump(tester, '', 'DG-1042'); + expect(find.text('Bag DG-1042'), findsOneWidget); + expect( + tester.getSize(find.byType(StopSourceLine)).height, + greaterThan(0), + ); + }); + }); + + group('the stop row on a service tenant', () { + /// [withSource] mirrors the payload difference between the two tenants: a + /// parcel booking has no kitchen and no bag label to send, which is why a + /// Doormile card is unchanged by P2 — the line is driven by the data, not + /// by who the rider works for. + Map stop({bool withSource = true}) => { + 'orderid': '1', + 'step': 1, + 'customername': 'Priya Sundaram', + 'pickupaddress': '2B Lakshmi Flats, 4th Main Road, Anna Nagar East', + 'status': 'ASSIGNED', + 'type': 'delivery', + 'quantity': 1, + if (withSource) 'kitchenname': 'Kitchen 1', + if (withSource) 'baglabel': 'DG-1042', + 'pickuplat': 13.08, + 'pickuplon': 80.21, + }; + + Future pumpRoute( + WidgetTester tester, { + bool withSource = true, + }) async { + tester.view.physicalSize = const Size(1080, 1800); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final trip = Trip.fromStops( + id: 't1', + stops: [stop(withSource: withSource)], + ); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard(trip: trip).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // ── The row's marks moved up to the place ── + // + // A card carried a work-type chip and a state chip of its own, and the + // truncation test above was about the two of them sharing one line. Neither + // is on a timeline row: the type is progressive disclosure in the detail + // sheet, and the *state* belongs to the place rather than to each order + // under it — five orders off one counter are all on the same rung, so five + // copies of the same chip said nothing five times. + // + // What survives is the half that was always the contract: the kitchen is + // named once, and the bag the order travels in is on the row. + + testWidgets('the kitchen is named once and the bag rides on the row', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpRoute(tester); + + // Named once, by the place — not again on every order under it. + expect(find.text('Kitchen 1'), findsOneWidget); + // The label the kitchen printed, straight from [BagManifest]. The `Bag ` + // prefix belonged to the card's chip and went with it, which stopped a + // printed `DG-1042` reading as `Bag DG-1042`. + expect(find.text('DG-1042'), findsOneWidget); + // The count the rider checks the shelf against. + expect(find.text('1 bag'), findsOneWidget); + }); + + testWidgets('a parcel route keeps a bare pending row', (tester) async { + ServiceProfile.setActive(ServiceProfile.parcel); + await pumpRoute(tester, withSource: false); + // The row underneath carries the decision, so a chip would be a caption + // on a control. + expect(find.text('Awaiting your decision'), findsNothing); + // And nothing from the meal run leaks onto a parcel route: no counter to + // group under, so the stop is its own node and no bag arithmetic applies. + expect(find.text('Kitchen 1'), findsNothing); + expect(find.text('MILK'), findsNothing); + expect(find.text('1 order · 1 bag'), findsNothing); + expect(find.text('Priya Sundaram'), findsOneWidget); + }); + }); +} diff --git a/test/service_flow_test.dart b/test/service_flow_test.dart new file mode 100644 index 0000000..ccf92b5 --- /dev/null +++ b/test/service_flow_test.dart @@ -0,0 +1,781 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:slide_to_submit_button/slide_to_submit_button.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/selection_bar.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// P3/P4 — the collect-then-deliver run. +/// +/// The rule the whole flow hangs on: **Home holds an order until it is in the +/// rider's hands, Bookings holds it after.** Every test here is a way that rule +/// can break. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map meal({ + required String id, + required int step, + required String kitchen, + String kitchenId = '', + String status = 'ASSIGNED', + }) => { + 'orderid': id, + 'step': step, + 'pickupcustomer': 'Customer $id', + 'pickupaddress': '$step Main Road, Anna Nagar', + 'orderstatus': status, + 'type': 'delivery', + 'quantity': 1, + 'kitchenname': kitchen, + if (kitchenId.isNotEmpty) 'kitchenid': kitchenId, + 'baglabel': 'DG-$id', + 'pickuplat': 13.08, + 'pickuplon': 80.21, + }; + + group('the collected state', () { + final stop = meal(id: '1', step: 1, kitchen: 'Kitchen 1'); + + test('carrying it beats a server view that still says accepted', () { + // The refetch is always a poll or two behind the rider's own hands. If + // the server won here the card would bounce back onto Home after he had + // already left the kitchen with the food. + final state = stopStateOf( + {...stop, 'orderstatus': 'accepted'}, + acceptedIds: {'1'}, + rejectedIds: const {}, + collectedIds: {'1'}, + ); + expect(state, StopState.collected); + }); + + test('but a delivered stop is still delivered', () { + // `picked` is the hand-over at the door and it is terminal — nothing + // local may drag it backwards into "still carrying it". + final state = stopStateOf( + {...stop, 'orderstatus': 'picked'}, + acceptedIds: const {}, + rejectedIds: const {}, + collectedIds: {'1'}, + ); + expect(state, StopState.done); + }); + + test('a parcel route never sees the state at all', () { + final state = stopStateOf( + stop, + acceptedIds: {'1'}, + rejectedIds: const {}, + // Default empty — no Doormile call site passes this. + ); + expect(state, StopState.accepted); + }); + + test('it counts as committed but not as resolved', () { + // Committed: he owes somebody a delivery. Not resolved: the trip is not + // finished, and a ring that read 100% with ten meals in the box would be + // telling him he could go home. + expect(StopState.collected.isCommitted, isTrue); + expect(StopState.collected.isResolved, isFalse); + }); + }); + + group('Home holds it until it is in his hands', () { + test('an accepted meal stays on Home; an accepted parcel does not', () { + ServiceProfile.setActive(ServiceProfile.meals); + final trip = Trip.fromStops( + id: 't', + stops: [meal(id: '1', step: 1, kitchen: 'Kitchen 1')], + ); + final card = TripCard(trip: trip, acceptedIds: const {'1'}); + // Rendering is asserted below; this is the rule underneath it. + expect(ServiceProfile.active.handsOffAtCollection, isTrue); + expect(card.trip.stops.length, 1); + + ServiceProfile.setActive(ServiceProfile.parcel); + expect(ServiceProfile.active.handsOffAtCollection, isFalse); + }); + + testWidgets('the row is on Home when accepted, gone once collected', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final trip = Trip.fromStops( + id: 't', + stops: [meal(id: '1', step: 1, kitchen: 'Kitchen 1')], + ); + + Future pump(Set collected) async { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + acceptedIds: const {'1'}, + collectedIds: collected, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + await pump(const {}); + expect(find.text('Customer 1'), findsOneWidget); + expect(find.text('Accepted'), findsOneWidget); + + // Collected → it belongs to Bookings now. + await pump({'1'}); + expect(find.text('Customer 1'), findsNothing); + }); + }); + + group('kitchen grouping', () { + testWidgets('one heading per kitchen, in route order', (tester) async { + ServiceProfile.setActive(ServiceProfile.meals); + tester.view.physicalSize = const Size(1080, 3000); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final trip = Trip.fromStops( + id: 't', + stops: [ + meal(id: '1', step: 1, kitchen: 'Kitchen 1', kitchenId: 'K1'), + meal(id: '2', step: 2, kitchen: 'Kitchen 1', kitchenId: 'K1'), + meal(id: '3', step: 3, kitchen: 'Kitchen 2', kitchenId: 'K2'), + ], + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + acceptedIds: const {'1', '2', '3'}, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Two kitchens, named once each however many meals hang off them. + expect(find.text('Kitchen 1'), findsOneWidget); + expect(find.text('Kitchen 2'), findsOneWidget); + // No second bulk control up here: the heading gathers the counter's + // orders for the bar, and the bar is the only thing that commits. + expect(find.textContaining('Collect '), findsNothing); + // One order, one bag — the two numbers the rider checks against the + // shelf, and they are the same number by definition. See [BagManifest]. + expect(find.text('2 bags'), findsOneWidget); + expect(find.text('1 bag'), findsOneWidget); + }); + + testWidgets('a heading gathers one rung, never a mixed batch', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + List? picked; + final trip = Trip.fromStops( + id: 't', + stops: [ + meal(id: '1', step: 1, kitchen: 'Kitchen 1'), + meal(id: '2', step: 2, kitchen: 'Kitchen 1'), + ], + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + // Only one of the two taken on. + acceptedIds: const {'1'}, + // The group's select-all is a *selection-mode* control now — + // it appears once the rider has ticked something and leaves + // when he has not, rather than sitting in every kitchen all + // day. Order 2 is ticked here, which puts the run in that + // mode without touching the accepted rung this test is about. + selectedIds: const {'2'}, + onToggleSelect: (_) {}, + onSelectAll: (ids) => picked = ids, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The header still says how much of the counter is not his yet. + expect(find.text('2 bags · 1 to accept'), findsOneWidget); + + // And its tick gathers only the orders on one rung. He cannot be handed + // food for an order he has not taken on, so a heading that swept both + // into one selection would offer a verb that is wrong for half of them — + // see `_selectionAction`, which falls back to Accept on a mixed set. + // Ported: the kitchen heading's tick became the group's one selection + // control. Same job, same rule — it gathers the counter, on one rung. + await tester.tap(find.textContaining('Select all')); + await tester.pumpAndSettle(); + expect(picked, ['1'], reason: 'only the accepted one'); + }); + }); + + // ── The pickup confirmation is the only place a handover is confirmed ── + // + // There used to be two bulk flows at a counter: this ladder, and a separate + // `Collect N` sheet with its own tick-list. They wrote different state and + // taught two mental models for one physical act. The ladder won; these tests + // are the load sheet's contracts, moved onto the sheet that absorbed them — + // the short pick, the empty crate, and the split between what he took and + // what he did not. + group('the pickup confirmation', () { + Future open( + WidgetTester tester, + int count, { + List markMissing = const [], + }) async { + ServiceProfile.setActive(ServiceProfile.meals); + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + RungOutcome? seen; + final stops = [ + for (var i = 1; i <= count; i++) + meal(id: '$i', step: i, kitchen: 'Kitchen 1'), + ]; + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: StopActionSheet( + stage: StopStatus.arrived, + placeName: 'Kitchen 1', + count: stops.length, + lines: BagManifest.forGroup(stops), + onConfirm: (outcome) async { + seen = outcome; + return null; + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + if (markMissing.isNotEmpty) { + await tester.tap(find.text('Report missing bag')); + await tester.pumpAndSettle(); + for (final name in markMissing) { + await tester.tap(find.text(name)); + await tester.pumpAndSettle(); + } + } + return seen; + } + + testWidgets('nothing is marked by default — the exception is what he marks', ( + tester, + ) async { + await open(tester, 3); + + // A screen that makes him tick three boxes to record "nothing was wrong" + // is a screen he learns to dismiss without reading. So the common case is + // a list he reads, and the shortfall is one tap away. + expect(find.text('3 bags'), findsOneWidget); + expect(find.text('Report missing bag'), findsOneWidget); + expect(find.text('Missing'), findsNothing); + expect( + find.text('Make sure all 3 bags are loaded before continuing.'), + findsOneWidget, + ); + }); + + testWidgets('marking one reports a short pick, and says so', ( + tester, + ) async { + await open(tester, 3, markMissing: ['Customer 2']); + + expect(find.text('2 bags · 1 missing'), findsOneWidget); + expect(find.text('Missing'), findsOneWidget); + expect( + find.text('1 of 3 not supplied — they will leave your route.'), + findsOneWidget, + ); + expect(find.text('Slide to confirm pickup'), findsOneWidget); + }); + + testWidgets('taking nothing at all is not a collection', (tester) async { + await open(tester, 2, markMissing: ['Customer 1', 'Customer 2']); + + // An empty crate is a wasted trip and a call to the hub, not a state to + // file silently. The gesture goes dead and the sheet says why. + expect(find.text('Nothing to collect'), findsOneWidget); + expect( + find.text('Nothing was handed over — call the hub before you leave.'), + findsOneWidget, + ); + final gate = tester.widget( + find + .ancestor( + of: find.byType(SlideToSubmit), + matching: find.byType(IgnorePointer), + ) + .first, + ); + expect(gate.ignoring, isTrue); + }); + + testWidgets('the outcome splits what he took from what he did not', ( + tester, + ) async { + RungOutcome? captured; + ServiceProfile.setActive(ServiceProfile.meals); + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final stops = [ + meal(id: '1', step: 1, kitchen: 'Kitchen 1'), + meal(id: '2', step: 2, kitchen: 'Kitchen 1'), + ]; + final sheet = StopActionSheet( + stage: StopStatus.arrived, + placeName: 'Kitchen 1', + count: stops.length, + lines: BagManifest.forGroup(stops), + onConfirm: (outcome) async { + captured = outcome; + return null; + }, + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp(home: Scaffold(body: sheet)), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Report missing bag')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Customer 2')); + await tester.pumpAndSettle(); + + // Drive the confirmation the way the slide does, without simulating a + // 300px drag: what is pinned here is the *payload*, not the gesture. + final state = tester.state(find.byType(StopActionSheet)); + // ignore: avoid_dynamic_calls + await (state as dynamic).confirmForTest(); + await tester.pumpAndSettle(); + + expect(captured, isNotNull); + expect(captured!.missingOrderIds, ['2']); + expect(captured!.isClean, isFalse); + }); + }); + + group('the collected store', () { + test('records, then clears once delivered', () async { + await addCollectedOrderIds(['1', '2']); + expect(await getCollectedOrderIds(), {'1', '2'}); + + await removeCollectedOrderIds(['1']); + expect(await getCollectedOrderIds(), {'2'}); + }); + + test('a short pick is its own record, not a skip', () async { + await addNotLoadedOrderIds(['9']); + expect(await getNotLoadedOrderIds(), {'9'}); + // Nobody was visited and the customer did nothing wrong, so it must not + // land in the skip store where it would read as a failed doorstep. + expect(await getSkippedOrderIds(), isEmpty); + }); + + test('a day can be wiped so ids never leak into tomorrow', () async { + await addCollectedOrderIds(['1']); + await addNotLoadedOrderIds(['2']); + await clearServiceRunState(); + expect(await getCollectedOrderIds(), isEmpty); + expect(await getNotLoadedOrderIds(), isEmpty); + }); + }); + + group('accept does not hand the meal over to Bookings', () { + /// The gate Bookings applies to decide what the rider is working now. + /// Mirrors the filter in `pickups.dart` so the rule can be asserted without + /// standing up the whole tab. + Future> workableIds() async => + ServiceProfile.active.handsOffAtCollection + ? await getCollectedOrderIds() + : await getAcceptedOrderIds(); + + test( + 'accepted-but-not-collected is not workable on a service run', + () async { + ServiceProfile.setActive(ServiceProfile.meals); + // Accepting writes to the accepted store — that is how the stop knows it + // has been taken on, and Home needs it to render "Accepted". + await addAcceptedBookings([ + {'orderid': '1', 'orderstatus': 'accepted'}, + ]); + + // But he is carrying nothing yet, so Bookings must be empty. Letting + // accept fill that tab hands him a delivery list for food still being + // cooked, and empties Home where the collection is organised. + expect(await workableIds(), isEmpty); + + await addCollectedOrderIds(['1']); + expect(await workableIds(), {'1'}); + }, + ); + + test('a parcel is workable the moment it is accepted', () async { + ServiceProfile.setActive(ServiceProfile.parcel); + await addAcceptedBookings([ + {'orderid': '9', 'orderstatus': 'accepted'}, + ]); + // Unchanged behaviour: for a parcel, accepting IS the hand-off. + expect(await workableIds(), {'9'}); + }); + + test('the two stores stay independent', () async { + ServiceProfile.setActive(ServiceProfile.meals); + await addAcceptedBookings([ + {'orderid': '1', 'orderstatus': 'accepted'}, + {'orderid': '2', 'orderstatus': 'accepted'}, + ]); + await addCollectedOrderIds(['1']); + + // Both taken on; only one in his hands. Home keeps the second, Bookings + // takes the first. + expect(await getAcceptedOrderIds(), {'1', '2'}); + expect(await getCollectedOrderIds(), {'1'}); + }); + }); + + group('the ladder, worked from the selection bar', () { + // A rider at a kitchen with five parcels takes one handover, so he ticks + // the five and slides once per rung. The row briefly carried its own + // Arrived / Picked up buttons — five buttons pressed twice for one event — + // and they are gone. + + test('each rung names itself in the rider\'s voice', () { + // "Mark as…" rather than "I've…": the button opens a sheet that then + // asks him to confirm the same words, so the two read as one action. + expect(SelectionAction.accept.label, 'Accept'); + expect(SelectionAction.arrive.label, 'Mark as Arrived'); + expect(SelectionAction.pickUp.label, 'Mark as Picked'); + }); + + test('each rung writes the right status token', () { + expect(SelectionAction.accept.status, 'ACCEPTED'); + expect(SelectionAction.arrive.status, 'ARRIVED'); + expect(SelectionAction.pickUp.status, 'PICKED'); + }); + + test( + 'the two that move parcels are confirmed in a sheet; accepting is not', + () { + // Accepting is reversible and happens in bulk at the start of a shift, so + // it commits from the bar. "Picked" is the rider asserting he is holding + // somebody's lunch — that one gets a sheet with a slide in it, where + // there is room to say which counter and how many orders he is speaking + // for. The slide used to be squeezed inline onto the bar itself. + expect(SelectionAction.accept.confirmsInSheet, isFalse); + expect(SelectionAction.arrive.confirmsInSheet, isTrue); + expect(SelectionAction.pickUp.confirmsInSheet, isTrue); + }, + ); + + Future pumpRow( + WidgetTester tester, { + required String status, + Set accepted = const {'1'}, + }) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final trip = Trip.fromStops( + id: 't', + stops: [meal(id: '1', step: 1, kitchen: 'Kitchen 1', status: status)], + ); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + acceptedIds: accepted, + onToggleSelect: (_) {}, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // ── The rung is pressed on the bar and *committed* in a sheet ── + // + // The bar's job at this point in the shift is to be unmissable: he is + // holding a crate at a counter. The commit's job is to be deliberate. Those + // are opposite requirements, which is why they are now two surfaces — + // a full-width button that opens [StopActionSheet], and a slide inside it. + Future pumpBar( + WidgetTester tester, { + required SelectionAction action, + required VoidCallback onAdvance, + }) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: SelectionBar( + count: 3, + action: action, + onAccept: null, + onClear: () {}, + onAdvance: () async => onAdvance(), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the bar names the rung and how many it covers', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpBar(tester, action: SelectionAction.arrive, onAdvance: () {}); + + expect(find.text('Mark as Arrived (3)'), findsOneWidget); + // Nothing shares the bar with it: a stop already taken on cannot be + // rejected from here, and the count is on the button itself. + expect(find.text('Reject'), findsNothing); + expect(find.textContaining('selected'), findsNothing); + }); + + testWidgets('the pick-up rung is the same control, renamed', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpBar(tester, action: SelectionAction.pickUp, onAdvance: () {}); + expect(find.text('Mark as Picked (3)'), findsOneWidget); + }); + + testWidgets('pressing it hands off rather than writing anything', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + var opened = 0; + await pumpBar( + tester, + action: SelectionAction.arrive, + onAdvance: () => opened++, + ); + + await tester.tap(find.text('Mark as Arrived (3)')); + await tester.pumpAndSettle(); + + // The press opens the confirmation; the slide inside it is what commits. + expect(opened, 1); + }); + + testWidgets('a kitchen heading ticks every order off that counter', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + List? picked; + final trip = Trip.fromStops( + id: 't', + stops: [ + meal( + id: '1', + step: 1, + kitchen: 'Kitchen 1', + kitchenId: 'K1', + status: 'accepted', + ), + meal( + id: '2', + step: 2, + kitchen: 'Kitchen 1', + kitchenId: 'K1', + status: 'accepted', + ), + meal( + id: '3', + step: 3, + kitchen: 'Kitchen 2', + kitchenId: 'K2', + status: 'accepted', + ), + ], + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + acceptedIds: const {'1', '2', '3'}, + // Selection mode, entered on the *other* kitchen — which also + // sharpens the assertion below: Kitchen 2 has a live tick of + // its own, and gathering Kitchen 1 must not disturb it. + selectedIds: const {'3'}, + onToggleSelect: (_) {}, + onSelectAll: (ids) => picked = ids, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // He arrives at a kitchen, not at an order — so the group's own control + // is where the whole counter is gathered for the bar underneath. + await tester.tap(find.textContaining('Select all')); + await tester.pumpAndSettle(); + + expect(picked, ['1', '2'], reason: 'the other kitchen is left alone'); + }); + + testWidgets('the row states where the stop is and offers no control', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpRow(tester, status: 'accepted'); + expect(find.text('Customer 1'), findsOneWidget); + expect(find.text('Mark as Arrived'), findsNothing); + expect(find.text('Arrived'), findsNothing); + expect(find.text('Picked up'), findsNothing); + }); + + testWidgets('the tick survives the accept, so the set can be slid', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpRow(tester, status: 'accepted'); + // Without this the bar disappears the instant he accepts the very stops + // he is about to walk into a kitchen with. + expect(find.byType(Checkbox), findsNothing); // it is a custom box + // Ported: the card's tick box became the timeline row itself, which + // announces as a selection control while one is meaningful. Same + // contract — the affordance outlives the accept on a meal run. + expect(find.bySemanticsLabel(RegExp('^Select ')), findsOneWidget); + }); + + testWidgets('a parcel row loses its tick once accepted', (tester) async { + ServiceProfile.setActive(ServiceProfile.parcel); + await pumpRow(tester, status: 'accepted'); + // Ticking exists only to choose what to take on. + expect(find.bySemanticsLabel(RegExp('^Select ')), findsNothing); + }); + + testWidgets('picked up leaves Home', (tester) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpRow(tester, status: 'picked'); + expect(find.text('Customer 1'), findsNothing); + }); + }); + + group('the doorstep chain', () { + // "I've handed over" → next order. Everything that used to sit between + // those two is gated off, and each gate is a different mechanism, so they + // are asserted together rather than trusted individually. + + test('nothing stands between the hand-over and the next order', () { + ServiceProfile.setActive(ServiceProfile.meals); + final p = ServiceProfile.active; + + // The proof-of-work page — parcel ticks, weight, condition, OTP, review. + expect(p.needsVerification, isFalse); + // The camera on the final rung. + expect(p.needsProofPhoto, isFalse); + // The payment page, and the amount that would open it. + expect(p.collectsCash, isFalse); + expect(stopCollectionAmount({'collectionamt': 450}), 0); + expect(stopCollectionAmount({'codamount': '₹1,200'}), 0); + }); + + test('a parcel stop keeps every one of them', () { + ServiceProfile.setActive(ServiceProfile.parcel); + final p = ServiceProfile.active; + expect(p.needsVerification, isTrue); + expect(p.needsProofPhoto, isTrue); + expect(p.collectsCash, isTrue); + expect(stopCollectionAmount({'collectionamt': 450}), 450); + }); + }); +} diff --git a/test/service_profile_test.dart b/test/service_profile_test.dart new file mode 100644 index 0000000..ad6d96e --- /dev/null +++ b/test/service_profile_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// One app, two lines of work. The tests that matter here are about the +/// direction the resolver fails in: an unconfigured or unrecognised tenant must +/// land on the parcel line, because that is the larger business and the failure +/// it cannot afford is a rider losing the screen that collects a customer's +/// cash. Showing a meal rider a payment prompt he can dismiss is the cheap +/// mistake. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Resolves through the free function, not the GetX controller — see the + /// note on [ServiceProfile.active] for why data code must not need a + /// container. [ServiceProfile.setActive] is what `TenantController.load` + /// would have done. + Future resolveWith(Map prefs) async { + SharedPreferences.setMockInitialValues(prefs); + final profile = await resolveServiceProfile(); + ServiceProfile.setActive(profile); + return profile; + } + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('tenant resolution', () { + test( + 'an unknown tenant falls back to parcel, not to the meal flow', + () async { + final p = await resolveWith({'tenantid': 4242}); + expect(p.line, ServiceLine.parcel); + expect(p.collectsCash, isTrue); + expect(p.needsVerification, isTrue); + }, + ); + + test('a login with no tenant at all still gets a working app', () async { + final p = await resolveWith({}); + expect(p.line, ServiceLine.parcel); + }); + + test('the tenant name selects meals, case and spacing aside', () async { + final p = await resolveWith({ + 'tenantid': 0, + 'tenantname': ' DailyGrubs ', + }); + expect(p.line, ServiceLine.milkMan); + }); + + test( + 'a meal rider is not asked for money, proof or a verification form', + () async { + final p = await resolveWith({'tenantname': 'meals'}); + expect(p.collectsCash, isFalse); + expect(p.needsVerification, isFalse); + expect(p.needsProofPhoto, isFalse); + // He cannot decline one subscriber's lunch. + expect(p.acceptsPerStop, isFalse); + expect(p.sourceIsKitchen, isTrue); + }, + ); + + test( + 'an unrecognised name falls through to the id rather than deciding', + () async { + // A client the app has never been told about must not guess its way onto + // the meal flow. + final p = await resolveWith({ + 'tenantname': 'someclient', + 'tenantid': 1, + }); + expect(p.line, ServiceLine.parcel); + }, + ); + }); + + group('the money gate', () { + final stop = {'collectionamt': 450}; + + test( + 'a parcel route still collects what the stop says it collects', + () async { + await resolveWith({'tenantid': 1}); + expect(stopCollectionAmount(stop), 450); + }, + ); + + test('a meal tenant collects nothing, whatever the payload says', () async { + await resolveWith({'tenantname': 'meals'}); + // The one gate every "is anything owed?" branch in the app reads. + expect(stopCollectionAmount(stop), 0); + expect(stopCollectionAmount({'codamount': '₹1,200'}), 0); + }); + + test('the alternate spellings still work on a parcel route', () async { + await resolveWith({'tenantid': 1}); + expect(stopCollectionAmount({'pickupamt': '₹1,200'}), 1200); + expect(stopCollectionAmount({'codamount': 75.5}), 75.5); + }); + }); + + group('the tenant id sent at login', () { + test('an ordinary build declares none, and the field is omitted', () { + // `--dart-define=TENANT_ID` is unset here, as it is in every default + // build. `hasTenantId` is what gates the field onto the request body, so + // a plain APK asks the server to decide rather than asserting a tenant. + expect(MilerApi.tenantId, 0); + expect(MilerApi.hasTenantId, isFalse); + }); + + test('zero is never a tenant', () { + // Sending `tenantid: 0` would look like a real lookup key to the server. + // The only way to say "you decide" is to leave the field out, so the + // guard is > 0 rather than a null check. + expect(MilerApi.hasTenantId, MilerApi.tenantId > 0); + }); + }); +} diff --git a/test/service_vocabulary_test.dart b/test/service_vocabulary_test.dart new file mode 100644 index 0000000..d6b3ae9 --- /dev/null +++ b/test/service_vocabulary_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE APP, TWO VOCABULARIES +/// +/// The two rider apps this one replaces disagreed about what a job is called: +/// the parcel app said **booking**, the meal app said **delivery**. Merging +/// them did not make either wrong — a rider collecting a consignment for the +/// hub is not delivering it, and a rider handing somebody lunch has not taken a +/// booking — so the noun follows the line of work and a rider only ever sees +/// the word he already uses. +/// +/// These tests are the contract every screen reads through. Get them wrong and +/// a meal rider is told to "work it from Bookings", a tab he does not have. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the noun for a job', () { + test('a parcel route takes bookings', () { + const p = ServiceProfile.parcel; + expect(p.jobNoun, 'booking'); + expect(p.jobNounPlural, 'bookings'); + expect(p.workTabLabel, 'Bookings'); + expect(p.completionVerb, 'Picked up'); + }); + + test('a meal run makes deliveries', () { + const p = ServiceProfile.meals; + expect(p.jobNoun, 'delivery'); + expect(p.jobNounPlural, 'deliveries'); + expect(p.workTabLabel, 'Deliveries'); + expect(p.completionVerb, 'Delivered'); + }); + + test('the tab label is the plural, title-cased — never hand-written', () { + // Two strings for one word is how "Deliveries" ends up on the tab while a + // sentence three screens away still says "bookings". + for (final p in [ServiceProfile.parcel, ServiceProfile.meals]) { + expect(p.workTabLabel.toLowerCase(), p.jobNounPlural); + } + }); + }); + + group('what the app is called', () { + test('both lines are Doormile — the rider has one employer', () { + // The crate he is carrying does not change who he works for, and no + // screen names a company that no longer exists. + expect(ServiceProfile.parcel.label, 'Doormile'); + expect(ServiceProfile.meals.label, startsWith('Doormile')); + }); + }); + + group('what kind of stop this is', () { + test('a meal run drops — it does not collect at a customer door', () { + ServiceProfile.setActive(ServiceProfile.meals); + // The payload names nothing. Falling through to "pickup" here is what put + // the word "Picked up" on the confirm button at a subscriber's flat. + expect(stopKindOf({}), StopKind.delivery); + expect(stopKindOf({}).isDelivery, isTrue); + }); + + test('an explicit label still wins over the line', () { + ServiceProfile.setActive(ServiceProfile.meals); + // The kitchen leg is a genuine collection, and the collection gate reads + // the real kind underneath whatever the chip says. + expect(stopKindOf({'type': 'pickup'}), StopKind.pickup); + expect(stopKindOf({'type': 'combined'}), StopKind.combined); + }); + + test('a parcel route is unchanged — an unlabelled stop is a pickup', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect(stopKindOf({}), StopKind.pickup); + }); + }); + + group('the line of work', () { + test('the two lines are told apart by capability, not by name', () { + // Every screen asks "does this line collect cash?", never "is this the + // meal tenant?" — which is what makes a third kind of client a new + // profile rather than a third UI. + expect(ServiceProfile.parcel.collectsCash, isTrue); + expect(ServiceProfile.meals.collectsCash, isFalse); + expect(ServiceProfile.meals.sourceIsKitchen, isTrue); + expect(ServiceProfile.meals.handsOffAtCollection, isTrue); + expect(ServiceProfile.parcel.handsOffAtCollection, isFalse); + }); + + test('only the meal line photographs a load in one shot', () { + expect(ServiceProfile.meals.bulkLoadProof, isTrue); + expect(ServiceProfile.parcel.bulkLoadProof, isFalse); + }); + }); +} diff --git a/test/sheet_kit_test.dart b/test/sheet_kit_test.dart new file mode 100644 index 0000000..be87ce1 --- /dev/null +++ b/test/sheet_kit_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/duty_sheet.dart'; +import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE SHEET KIT — the rules, held +/// +/// The kit exists because six presentation styles had grown around one kind +/// of surface (see the audit in `miler_sheet_kit.dart`). What stops a seventh +/// growing back is not the kit's existence but these tests: the shared handle +/// on the sheets a rider actually uses, and the one inset rule that prevents +/// the double-SafeArea CTA drift from returning. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Future pump( + WidgetTester tester, + Widget child, { + EdgeInsets viewInsets = EdgeInsets.zero, + EdgeInsets viewPadding = EdgeInsets.zero, + }) async { + tester.view.physicalSize = const Size(1170, 2532); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: MediaQuery( + data: MediaQueryData( + size: const Size(390, 844), + viewInsets: viewInsets, + viewPadding: viewPadding, + ), + // NOT a Scaffold: `resizeToAvoidBottomInset` consumes viewInsets + // before the sheet sees them, which is exactly the environment a + // modal route does NOT provide — the sheet must read the keyboard + // itself, which is the behaviour under test. + child: Material( + color: Colors.black, + child: Align(alignment: Alignment.bottomCenter, child: child), + ), + ), + ), + ), + ); + await tester.pump(); + } + + group('the scaffold', () { + testWidgets('pays the larger of keyboard and gesture bar, never both', ( + tester, + ) async { + // A gesture-bar phone with the keyboard up. The old double-SafeArea bug + // paid 300 + 34; the rule is max(300, 34). + await pump( + tester, + const MilerSheetScaffold(child: SizedBox(height: 40)), + viewInsets: const EdgeInsets.only(bottom: 300), + viewPadding: const EdgeInsets.only(bottom: 34), + ); + + final padding = tester.widget( + find.byType(AnimatedPadding).first, + ); + expect( + padding.padding, + const EdgeInsets.only(bottom: 300), + reason: + 'the keyboard replaces the gesture bar — summing them floats ' + 'the CTA a bar height above the keyboard', + ); + }); + + testWidgets('with no keyboard the gesture bar is still paid', ( + tester, + ) async { + await pump( + tester, + const MilerSheetScaffold(child: SizedBox(height: 40)), + viewPadding: const EdgeInsets.only(bottom: 34), + ); + + final padding = tester.widget( + find.byType(AnimatedPadding).first, + ); + expect(padding.padding, const EdgeInsets.only(bottom: 34)); + }); + + testWidgets('draws the shared handle once', (tester) async { + await pump(tester, const MilerSheetScaffold(child: SizedBox(height: 40))); + expect(find.byType(MilerSheetHandle), findsOneWidget); + }); + }); + + group('the migrated sheets carry the kit', () { + testWidgets('duty', (tester) async { + await pump( + tester, + DutySheet(goingOnline: true, onConfirm: () async => false), + ); + expect(find.byType(MilerSheetScaffold), findsOneWidget); + expect(find.byType(MilerSheetHandle), findsOneWidget); + }); + + testWidgets('the rung confirmation', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump( + tester, + StopActionSheet( + stage: StopStatus.accepted, + placeName: 'Vidhya Kitchen', + count: 2, + lines: const [ + BagLine(stop: {}, orderId: 'A', customer: 'Joe', bag: 'Bag 1'), + BagLine(stop: {}, orderId: 'B', customer: 'Arun', bag: 'Bag 2'), + ], + onConfirm: (_) async => null, + ), + ); + expect(find.byType(MilerSheetScaffold), findsOneWidget); + expect(find.byType(MilerSheetHandle), findsOneWidget); + // The manifest is still the sheet's content — the kit wraps, it does + // not swallow. + expect(find.text('Joe'), findsOneWidget); + expect(find.text('Bag 2'), findsOneWidget); + }); + }); + + group('the choice row', () { + testWidgets('selection is a soft fill and a tick, not a slab', ( + tester, + ) async { + await pump( + tester, + Material( + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + MilerSheetChoiceRow(label: 'Wrong address', selected: true), + MilerSheetChoiceRow(label: 'Out of area', selected: false), + ], + ), + ), + ); + + // The tick marks the chosen answer… + expect(find.byIcon(LucideIcons.check), findsNWidgets(2)); + final ticks = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(ticks, [ + 1.0, + 0.0, + ], reason: 'the tick shows on the selected row and only there'); + + // …and no row is ever a full-strength primary slab. The fill that broke + // this rule outshouted the destructive CTA under it. + final fills = tester + .widgetList(find.byType(AnimatedContainer)) + .map((c) => (c.decoration as BoxDecoration?)?.color) + .whereType(); + expect(fills, isNot(contains(ColorConstants.primary))); + }); + }); +} diff --git a/test/sheet_layout_test.dart b/test/sheet_layout_test.dart new file mode 100644 index 0000000..0329e42 --- /dev/null +++ b/test/sheet_layout_test.dart @@ -0,0 +1,192 @@ +import 'dart:async' show unawaited; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/duty_sheet.dart'; +import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; +import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; +import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE SHEETS, ACROSS THE PHONES THIS SHIPS TO +/// +/// Same shape as `activity_layout_test`: every rider-path sheet, opened +/// through its real `show` entry point (so the route, fabric and insets are +/// the ones production uses), at the four widths the brief names against the +/// text scales Android exposes. One assertion — **nothing throws** — because +/// a `RenderFlex` overflow is an exception under the test binding and that is +/// the cheapest honest "does it fit?". +/// ───────────────────────────────────────────────────────────────────────── +void main() { + setUpAll(() { + // The detail sheet's map caches tiles through path_provider, which has no + // host under the test binding. + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => Directory.systemTemp + .createTemp('sheet_layout') + .then((d) => d.path), + ); + }); + + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + const widths = [320.0, 375.0, 390.0, 412.0]; + const textScales = [1.0, 1.3, 2.0]; + + const lines = [ + BagLine( + stop: {}, + orderId: 'D1', + customer: 'Venkataraman Subramanian', + bag: 'Bag 1', + ), + BagLine(stop: {}, orderId: 'D2', customer: 'Arun Prakash', bag: 'Bag 2'), + BagLine(stop: {}, orderId: 'D3', customer: 'Priya Nair', bag: 'Bag 3'), + ]; + + Map stop() => { + 'orderid': 'D1', + 'type': 'delivery', + 'pickupcustomer': 'Venkataraman Subramanian', + 'sourcename': 'DailyGrubs RS Puram Central Kitchen', + 'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002', + 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'orderstatus': 'accepted', + 'pickupcontactno': '9787698259', + 'baglabel': 'Bag 1', + 'Quantity': 1, + 'collectionamt': '123456', + 'notes': 'Gate code 4411, leave with security if unreachable.', + 'pickuplat': 11.0168, + 'pickuplon': 76.9558, + 'droplat': 11.0208, + 'droplon': 76.9598, + }; + + Future sweep( + WidgetTester tester, + double width, + double scale, + Future Function(BuildContext) open, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userid': 1}); + + tester.view.physicalSize = Size(width * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + late BuildContext ctx; + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Builder( + builder: (c) { + ctx = c; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + + unawaited(open(ctx)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect( + tester.takeException(), + isNull, + reason: 'the sheet overflowed at ${width}pt / ${scale}x', + ); + } + + for (final width in widths) { + for (final scale in textScales) { + final tag = '${width.toInt()}pt / ${scale}x'; + + testWidgets('duty fits $tag', (tester) async { + await sweep( + tester, + width, + scale, + (c) => DutySheet.show( + c, + goingOnline: true, + onConfirm: () async => false, + ), + ); + }); + + testWidgets('the rung confirmation fits $tag', (tester) async { + await sweep( + tester, + width, + scale, + (c) => StopActionSheet.show( + c, + stage: StopStatus.arrived, + placeName: 'DailyGrubs RS Puram Central Kitchen', + count: 3, + lines: lines, + onConfirm: (_) async => null, + ), + ); + }); + + testWidgets('the pickup preview fits $tag', (tester) async { + await sweep( + tester, + width, + scale, + (c) => PickupPreviewSheet.show( + c, + placeName: 'DailyGrubs RS Puram Central Kitchen', + lines: lines, + meters: 3100, + ride: const Duration(minutes: 9), + onNavigate: () {}, + onCall: () {}, + ), + ); + }); + + testWidgets('the stop detail fits $tag', (tester) async { + await sweep( + tester, + width, + scale, + (c) => StopDetailSheet.show( + c, + stop: stop(), + stopNumber: 7, + bag: 'Bag 1', + riderLat: 11.0168, + riderLng: 76.9558, + ), + ); + }); + } + } +} diff --git a/test/sheet_shots_test.dart b/test/sheet_shots_test.dart new file mode 100644 index 0000000..7e85b39 --- /dev/null +++ b/test/sheet_shots_test.dart @@ -0,0 +1,279 @@ +@Tags(['shots']) +library; + +import 'dart:async' show unawaited; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/golden_fonts.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/duty_sheet.dart'; +import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; +import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; +import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; + +/// Visual harness for the bottom sheets. +/// +/// Each sheet body is pumped **through its own `show` entry point** where one +/// exists, over a busy stand-in page — a sheet is never seen against a blank +/// screen, and the barrier + rounded shape + background are exactly the things +/// being unified, so the shot must include them. Run with `--update-goldens`. +void main() { + setUpAll(() async { + // The detail sheet's map caches tiles through path_provider, which has no + // host in a test binding. Point it at the test runner's temp dir; the + // tiles themselves never load (no network in tests) and flutter_map + // degrades to its plain ground, which is fine for a layout shot. + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => Directory.systemTemp + .createTemp('sheet_shots') + .then((d) => d.path), + ); + await loadGoldenFonts(); + }); + + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + const lines = [ + BagLine(stop: {}, orderId: 'D1', customer: 'Joe Mathew', bag: 'Bag 1'), + BagLine(stop: {}, orderId: 'D2', customer: 'Arun Prakash', bag: 'Bag 2'), + BagLine(stop: {}, orderId: 'D3', customer: 'Priya Nair', bag: 'Bag 3'), + BagLine(stop: {}, orderId: 'D4', customer: 'Ravi Kumar', bag: 'Bag 4'), + BagLine(stop: {}, orderId: 'D5', customer: 'Kumar S', bag: 'Bag 5'), + ]; + + Map stop() => { + 'orderid': 'D1', + 'bookingno': 'DM-BK-16DA518D-6381', + 'type': 'delivery', + 'pickupcustomer': 'Joe Mathew', + 'name': 'Joe Mathew', + 'sourcename': 'Vidhya Kitchen', + 'kitchenname': 'Vidhya Kitchen', + 'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002', + 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', + 'orderstatus': 'accepted', + 'pickupcontactno': '9787698259', + 'baglabel': 'Bag 1', + 'Quantity': 1, + 'collectionamt': '2450', + 'pickuplat': 11.0168, + 'pickuplon': 76.9558, + 'droplat': 11.0208, + 'droplon': 76.9598, + }; + + /// A stand-in for the page under the sheet: enough structure that the + /// barrier's dim and the sheet's edge read against something real. + Widget ground() => Scaffold( + backgroundColor: ColorConstants.daylightSurface, + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + for (var i = 0; i < 10; i++) + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Container( + height: 84, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ), + ); + + Future shoot( + WidgetTester tester, + String name, + Future Function(BuildContext) open, { + double width = 390, + double height = 844, + double textScale = 1.0, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + SharedPreferences.setMockInitialValues({'userid': 1}); + + tester.view.physicalSize = Size(width * 3, height * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + late BuildContext pageContext; + await tester.pumpWidget( + // A RepaintBoundary of our own to capture from. Capturing a finder with + // no boundary makes the framework re-composite the retained layers, and + // an animated CTA's layer can land at its pre-flight offset — the first + // run of these shots had a ghost "Start navigation" floating at the top + // of the barrier. A real boundary repaints the whole tree at capture. + RepaintBoundary( + child: ScreenUtilInit( + designSize: Size(width, height), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + // Above the Navigator, not around `home`: a modal sheet is its own + // route, and an override wrapped around the page never reaches it — + // the first cut of this harness was quietly shooting every sheet at + // 1.0x while claiming 1.3x. + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Builder( + builder: (c) { + pageContext = c; + return ground(); + }, + ), + ), + ), + ), + ); + + unawaited(open(pageContext)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + await expectLater( + find.byType(RepaintBoundary).first, + matchesGoldenFile('shots/sheet_$name.png'), + ); + } + + testWidgets('duty — going on', (tester) async { + await shoot( + tester, + 'duty_on', + (c) => DutySheet.show(c, goingOnline: true, onConfirm: () async => false), + ); + }); + + testWidgets('duty — going off', (tester) async { + await shoot( + tester, + 'duty_off', + (c) => + DutySheet.show(c, goingOnline: false, onConfirm: () async => false), + ); + }); + + testWidgets('stop detail', (tester) async { + await shoot( + tester, + 'detail', + (c) => StopDetailSheet.show( + c, + stop: stop(), + stopNumber: 1, + riderLat: 11.0168, + riderLng: 76.9558, + ), + ); + }); + + testWidgets('confirm arrival', (tester) async { + await shoot( + tester, + 'action_arrive', + (c) => StopActionSheet.show( + c, + stage: StopStatus.accepted, + placeName: 'Vidhya Kitchen', + count: 5, + lines: lines, + onConfirm: (_) async => null, + ), + ); + }); + + testWidgets('confirm picked', (tester) async { + await shoot( + tester, + 'action_picked', + (c) => StopActionSheet.show( + c, + stage: StopStatus.arrived, + placeName: 'Vidhya Kitchen', + count: 5, + lines: lines, + onConfirm: (_) async => null, + ), + ); + }); + + testWidgets('pickup preview', (tester) async { + await shoot( + tester, + 'preview', + (c) => PickupPreviewSheet.show( + c, + placeName: 'Vidhya Kitchen', + lines: lines, + meters: 3100, + ride: const Duration(minutes: 9), + riderLat: 11.0168, + riderLng: 76.9558, + onNavigate: () {}, + onCall: () {}, + ), + ); + }); + + testWidgets('stop detail with notes and a long name', (tester) async { + await shoot( + tester, + 'detail_long', + (c) => StopDetailSheet.show( + c, + stop: { + ...stop(), + 'pickupcustomer': 'Venkataraman Subramanian', + 'name': 'Venkataraman Subramanian', + 'sourcename': 'DailyGrubs RS Puram Central Kitchen', + 'collectionamt': '123456', + 'notes': 'Gate code 4411, leave with security if unreachable.', + }, + stopNumber: 7, + bag: 'Bag 1', + riderLat: 11.0168, + riderLng: 76.9558, + ), + ); + }); + + testWidgets('confirm arrival on a 320pt phone at 1.3x', (tester) async { + await shoot( + tester, + 'action_arrive_small', + (c) => StopActionSheet.show( + c, + stage: StopStatus.accepted, + placeName: 'Vidhya Kitchen', + count: 5, + lines: lines, + onConfirm: (_) async => null, + ), + width: 320, + textScale: 1.3, + ); + }); +} diff --git a/test/shipment_review_test.dart b/test/shipment_review_test.dart new file mode 100644 index 0000000..eb3277c --- /dev/null +++ b/test/shipment_review_test.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/shipment_review.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// REVIEW & PAY +/// +/// ``` +/// verify parcel ──▶ shipment desk ──▶ REVIEW & PAY ──▶ order created +/// (no review) from · to animated tick +/// weight · fare tracking no +/// payment +/// [ Pay ₹150 ] +/// ``` +/// +/// Two things are pinned here. +/// +/// **The button names the amount.** A rider taking cash on a doorstep should +/// never have to remember what the total was, and the figure he quotes must be +/// the figure the screen collects — so both come from one place. +/// +/// **The success state is earned.** The tick and "your order has been created" +/// appear only after the create actually succeeded. A failure keeps the rider +/// on the review with the reason, because the alternative is him riding off +/// with the customer's money believing an order exists. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + Map capture({double price = 150}) => { + 'captured': true, + 'from': {'address': '9 Mettupalayam Road', 'pincode': '641002'}, + 'to': { + 'address': '12 Cross Cut Road', + 'pincode': '641012', + 'city': 'Coimbatore', + }, + 'weight': 2.5, + 'category': 'General', + 'servicetype': 'Normal', + 'zone': 'Local', + 'price': price, + 'currency': 'INR', + 'approxkm': 4.2, + }; + + Future pump( + WidgetTester tester, { + Map? shipment, + Future Function(ShipmentPayMethod, double)? onConfirm, + String tracking = 'DM-TRK-A1B2-42', + }) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: ShipmentReviewPage( + pickup: const {'orderid': '2001', 'pickupid': 2001}, + shipment: shipment ?? capture(), + trackingNo: () => tracking, + onConfirm: onConfirm ?? (_, _) async => null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('the review', () { + testWidgets('shows the shipment and the payment on one screen', ( + tester, + ) async { + await pump(tester); + + // Route + expect(find.text('FROM'), findsOneWidget); + expect(find.text('TO'), findsOneWidget); + expect(find.text('9 Mettupalayam Road'), findsOneWidget); + expect(find.text('12 Cross Cut Road'), findsOneWidget); + + // Shipment + expect(find.text('Shipment'), findsOneWidget); + expect(find.text('2.5 kg'), findsOneWidget); + expect(find.text('General'), findsOneWidget); + expect(find.text('Local'), findsOneWidget); + + // Payment + expect(find.text('Payment'), findsOneWidget); + expect(find.text('Cash'), findsOneWidget); + expect(find.text('UPI'), findsOneWidget); + }); + + testWidgets('the button says the amount out loud', (tester) async { + await pump(tester); + expect( + find.text('Pay ₹150'), + findsOneWidget, + reason: 'a rider at a doorstep should not have to remember the total', + ); + }); + + testWidgets('the amount on the button matches the amount in the summary', ( + tester, + ) async { + await pump(tester, shipment: capture(price: 275)); + expect(find.text('Pay ₹275'), findsOneWidget); + expect(find.text('₹275'), findsOneWidget); // the summary line + }); + + testWidgets('a zero-rated shipment asks for no money', (tester) async { + await pump(tester, shipment: capture(price: 0)); + expect(find.text('Create order'), findsOneWidget); + expect(find.textContaining('Pay '), findsNothing); + }); + + testWidgets('the distance is labelled as the estimate it is', ( + tester, + ) async { + await pump(tester); + expect(find.text('≈ 4.2 km'), findsOneWidget); + expect( + find.textContaining('straight line'), + findsOneWidget, + reason: + 'the fare comes from the zone, and saying so stops a rider ' + 'telling a customer the wrong basis for a charge', + ); + }); + }); + + group('paying', () { + testWidgets('passes the chosen method and the quoted amount', ( + tester, + ) async { + ShipmentPayMethod? seenMethod; + double? seenAmount; + + await pump( + tester, + onConfirm: (m, a) async { + seenMethod = m; + seenAmount = a; + return null; + }, + ); + + await tester.tap(find.text('UPI')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pay ₹150')); + await tester.pumpAndSettle(); + + expect(seenMethod, ShipmentPayMethod.upi); + expect(seenAmount, 150); + }); + + testWidgets('success shows the tick, the copy and the tracking number', ( + tester, + ) async { + await pump(tester); + await tester.tap(find.text('Pay ₹150')); + await tester.pumpAndSettle(); + + expect(find.text('Your order has been created'), findsOneWidget); + expect(find.byIcon(LucideIcons.check), findsOneWidget); + expect(find.text('₹150 collected by Cash'), findsOneWidget); + expect( + find.text('DM-TRK-A1B2-42'), + findsOneWidget, + reason: + 'a customer asking for the tracking number gets it on the ' + 'doorstep instead of a phone call to the hub', + ); + expect(find.text('Done'), findsOneWidget); + }); + + testWidgets('a failure keeps him on the review with the reason', ( + tester, + ) async { + await pump( + tester, + onConfirm: (_, _) async => 'Payment could not be recorded — no network', + ); + + await tester.tap(find.text('Pay ₹150')); + await tester.pumpAndSettle(); + + expect( + find.text('Your order has been created'), + findsNothing, + reason: + 'the order does not exist; claiming it does would send him ' + 'away with the customer\'s money and nothing on the hub screen', + ); + expect( + find.textContaining('Payment could not be recorded'), + findsOneWidget, + ); + // And he can try again. + expect(find.text('Pay ₹150'), findsOneWidget); + }); + + testWidgets('Done hands back the outcome the pickup flow reads', ( + tester, + ) async { + Map? popped; + + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + popped = await Navigator.of(context) + .push>( + MaterialPageRoute( + builder: (_) => ShipmentReviewPage( + pickup: const {'orderid': '2001'}, + shipment: capture(), + trackingNo: () => 'DM-TRK-1', + onConfirm: (_, _) async => null, + ), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pay ₹150')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + // The shape `map.dart` already branches on, so the hand-off screen, the + // compliance stamp and the next-stop list keep working untouched. + expect(popped?['outcome'], 'completed'); + expect(popped?['amountCollected'], 150); + expect(popped?['paymentMethod'], 'Cash'); + }); + }); + + group('the flow order', () { + test('logistics verifies, then captures, then reviews', () { + // The verify page drops its own review exactly where the shipment desk + // follows it — otherwise the rider confirms the parcel, describes the + // shipment, and confirms it all again. + expect(ServiceProfile.parcel.needsVerification, isTrue); + expect(ServiceProfile.parcel.capturesShipmentAddresses, isTrue); + expect(ServiceProfile.parcel.initiatesShipment, isTrue); + }); + + test('a milk run reaches none of it', () { + const p = ServiceProfile.milkMan; + expect(p.needsVerification, isFalse); + expect(p.capturesShipmentAddresses, isFalse); + expect(p.initiatesShipment, isFalse); + expect(p.collectsCash, isFalse); + }); + }); +} diff --git a/test/shots/activity_record.png b/test/shots/activity_record.png new file mode 100644 index 0000000..7ed8fa4 Binary files /dev/null and b/test/shots/activity_record.png differ diff --git a/test/shots/activity_round.png b/test/shots/activity_round.png new file mode 100644 index 0000000..f20b36a Binary files /dev/null and b/test/shots/activity_round.png differ diff --git a/test/shots/activity_small.png b/test/shots/activity_small.png new file mode 100644 index 0000000..312eddb Binary files /dev/null and b/test/shots/activity_small.png differ diff --git a/test/shots/activity_trips.png b/test/shots/activity_trips.png new file mode 100644 index 0000000..b09a2e0 Binary files /dev/null and b/test/shots/activity_trips.png differ diff --git a/test/shots/deliveries_card_active.png b/test/shots/deliveries_card_active.png new file mode 100644 index 0000000..61619f7 Binary files /dev/null and b/test/shots/deliveries_card_active.png differ diff --git a/test/shots/deliveries_card_picked.png b/test/shots/deliveries_card_picked.png new file mode 100644 index 0000000..fdefcc0 Binary files /dev/null and b/test/shots/deliveries_card_picked.png differ diff --git a/test/shots/deliveries_rail_21.png b/test/shots/deliveries_rail_21.png new file mode 100644 index 0000000..0a10c24 Binary files /dev/null and b/test/shots/deliveries_rail_21.png differ diff --git a/test/shots/deliveries_rail_21_fresh.png b/test/shots/deliveries_rail_21_fresh.png new file mode 100644 index 0000000..6dd8296 Binary files /dev/null and b/test/shots/deliveries_rail_21_fresh.png differ diff --git a/test/shots/deliveries_rail_21_small.png b/test/shots/deliveries_rail_21_small.png new file mode 100644 index 0000000..8c8aba7 Binary files /dev/null and b/test/shots/deliveries_rail_21_small.png differ diff --git a/test/shots/deliveries_rail_short.png b/test/shots/deliveries_rail_short.png new file mode 100644 index 0000000..6998fb2 Binary files /dev/null and b/test/shots/deliveries_rail_short.png differ diff --git a/test/shots/home_accepted.png b/test/shots/home_accepted.png new file mode 100644 index 0000000..a872a83 Binary files /dev/null and b/test/shots/home_accepted.png differ diff --git a/test/shots/home_all_selected.png b/test/shots/home_all_selected.png new file mode 100644 index 0000000..21f796a Binary files /dev/null and b/test/shots/home_all_selected.png differ diff --git a/test/shots/home_arrived.png b/test/shots/home_arrived.png new file mode 100644 index 0000000..2d3fcf7 Binary files /dev/null and b/test/shots/home_arrived.png differ diff --git a/test/shots/home_brief_open.png b/test/shots/home_brief_open.png new file mode 100644 index 0000000..ab63458 Binary files /dev/null and b/test/shots/home_brief_open.png differ diff --git a/test/shots/home_group_done.png b/test/shots/home_group_done.png new file mode 100644 index 0000000..05b9e3d Binary files /dev/null and b/test/shots/home_group_done.png differ diff --git a/test/shots/home_long_text.png b/test/shots/home_long_text.png new file mode 100644 index 0000000..445bae1 Binary files /dev/null and b/test/shots/home_long_text.png differ diff --git a/test/shots/home_offline.png b/test/shots/home_offline.png new file mode 100644 index 0000000..7ef0a71 Binary files /dev/null and b/test/shots/home_offline.png differ diff --git a/test/shots/home_one_kitchen.png b/test/shots/home_one_kitchen.png new file mode 100644 index 0000000..ddd54f6 Binary files /dev/null and b/test/shots/home_one_kitchen.png differ diff --git a/test/shots/home_parcel.png b/test/shots/home_parcel.png new file mode 100644 index 0000000..3598189 Binary files /dev/null and b/test/shots/home_parcel.png differ diff --git a/test/shots/home_pending.png b/test/shots/home_pending.png new file mode 100644 index 0000000..6cd6582 Binary files /dev/null and b/test/shots/home_pending.png differ diff --git a/test/shots/home_run_complete.png b/test/shots/home_run_complete.png new file mode 100644 index 0000000..b48ac96 Binary files /dev/null and b/test/shots/home_run_complete.png differ diff --git a/test/shots/home_scale_1_3x.png b/test/shots/home_scale_1_3x.png new file mode 100644 index 0000000..a317ac9 Binary files /dev/null and b/test/shots/home_scale_1_3x.png differ diff --git a/test/shots/home_scale_2x.png b/test/shots/home_scale_2x.png new file mode 100644 index 0000000..5eb0412 Binary files /dev/null and b/test/shots/home_scale_2x.png differ diff --git a/test/shots/home_selected.png b/test/shots/home_selected.png new file mode 100644 index 0000000..d0d8d8f Binary files /dev/null and b/test/shots/home_selected.png differ diff --git a/test/shots/home_three_kitchens.png b/test/shots/home_three_kitchens.png new file mode 100644 index 0000000..49a7455 Binary files /dev/null and b/test/shots/home_three_kitchens.png differ diff --git a/test/shots/home_two_kitchens.png b/test/shots/home_two_kitchens.png new file mode 100644 index 0000000..6cd6582 Binary files /dev/null and b/test/shots/home_two_kitchens.png differ diff --git a/test/shots/sheet_action_arrive.png b/test/shots/sheet_action_arrive.png new file mode 100644 index 0000000..2213d01 Binary files /dev/null and b/test/shots/sheet_action_arrive.png differ diff --git a/test/shots/sheet_action_arrive_small.png b/test/shots/sheet_action_arrive_small.png new file mode 100644 index 0000000..8c60608 Binary files /dev/null and b/test/shots/sheet_action_arrive_small.png differ diff --git a/test/shots/sheet_action_picked.png b/test/shots/sheet_action_picked.png new file mode 100644 index 0000000..184064f Binary files /dev/null and b/test/shots/sheet_action_picked.png differ diff --git a/test/shots/sheet_detail.png b/test/shots/sheet_detail.png new file mode 100644 index 0000000..70709ec Binary files /dev/null and b/test/shots/sheet_detail.png differ diff --git a/test/shots/sheet_detail_long.png b/test/shots/sheet_detail_long.png new file mode 100644 index 0000000..84fbee2 Binary files /dev/null and b/test/shots/sheet_detail_long.png differ diff --git a/test/shots/sheet_duty_off.png b/test/shots/sheet_duty_off.png new file mode 100644 index 0000000..c7e0af4 Binary files /dev/null and b/test/shots/sheet_duty_off.png differ diff --git a/test/shots/sheet_duty_on.png b/test/shots/sheet_duty_on.png new file mode 100644 index 0000000..c1bf0af Binary files /dev/null and b/test/shots/sheet_duty_on.png differ diff --git a/test/shots/sheet_preview.png b/test/shots/sheet_preview.png new file mode 100644 index 0000000..a64b271 Binary files /dev/null and b/test/shots/sheet_preview.png differ diff --git a/test/shots/verify_delivery.png b/test/shots/verify_delivery.png new file mode 100644 index 0000000..0b8e54b Binary files /dev/null and b/test/shots/verify_delivery.png differ diff --git a/test/shots/verify_pickup.png b/test/shots/verify_pickup.png new file mode 100644 index 0000000..1f80272 Binary files /dev/null and b/test/shots/verify_pickup.png differ diff --git a/test/shots/verify_shipment.png b/test/shots/verify_shipment.png new file mode 100644 index 0000000..b02e35f Binary files /dev/null and b/test/shots/verify_shipment.png differ diff --git a/test/skip_leaves_deliveries_test.dart b/test/skip_leaves_deliveries_test.dart new file mode 100644 index 0000000..1049d84 --- /dev/null +++ b/test/skip_leaves_deliveries_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A SKIPPED DELIVERY IS DONE WITH, NOT PARKED +/// +/// ── The regression ── +/// +/// A skip at a customer's door left the tab and came back. The in-memory set +/// that holds a just-finished stop off the list is what was doing the work, +/// and it dies with the process — so the next cold start put the stop the +/// rider had written off an hour earlier back at the top of his queue, while +/// its record sat in Activity saying otherwise. +/// +/// It is specifically a *skip* problem. A delivery closes the consignment, so +/// the backend stops calling it delivery work within a poll or two and the row +/// drops out on its own. A skip is a failed attempt: the server rightly leaves +/// the consignment `Out_for_Delivery`, and it will keep coming back for as long +/// as the day lasts unless the device remembers what it did. +/// +/// These pin the store rules that hold it off the tab and put it on Activity +/// under its own word. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({'userid': 38}); + ServiceProfile.setActive(ServiceProfile.milkMan); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map stop(String id) => { + 'orderid': id, + 'pickupid': int.parse(id), + 'orderstatus': 'outfordelivery', + 'pickupcustomer': 'SEQTEST Ukkadam', + }; + + group('the record', () { + test( + 'a skip is filed as a skip, not flattened into a cancellation', + () async { + await addCompletedBookings([stop('1001')], terminalStatus: 'skipped'); + + final filed = (await getCompletedBookings()).single; + expect(stopStatusOf(filed).isSkipped, isTrue); + expect( + stopStatusOf(filed).isCancelled, + isFalse, + reason: + 'a stop the rider walked away from and one the office called ' + 'off are different things, and Activity slices them apart', + ); + }, + ); + + test('the exact word wins over the cancelled shorthand', () async { + // `cancelled: true` is what suppresses the "delivery N of M" toast, so + // the skip path passes both — and the precise word has to survive. + await addCompletedBookings( + [stop('1001')], + cancelled: true, + terminalStatus: 'skipped', + ); + expect((await getCompletedBookings()).single['orderstatus'], 'skipped'); + }); + + test('a real cancellation still files as cancelled', () async { + await addCompletedBookings([stop('1001')], cancelled: true); + expect((await getCompletedBookings()).single['orderstatus'], 'cancelled'); + }); + + test('a delivery still files as delivered', () async { + await addCompletedBookings([stop('1001')]); + expect( + (await getCompletedBookings()).single['orderstatus'], + 'delivered', + reason: 'the milk run ends at the customer, not at a hub', + ); + }); + }); + + group('it stays off the deliveries tab', () { + test('the closed set survives the process that wrote it', () async { + await addCompletedBookings([stop('1001')], terminalStatus: 'skipped'); + + // What a cold start reads back — the same call the Deliveries tab makes + // to rebuild its just-finished set. + expect(await getCompletedOrderIds(), contains('1001')); + }); + + test('closing one stop does not close the rest of the load', () async { + await addCompletedBookings([stop('1001')], terminalStatus: 'skipped'); + expect(await getCompletedOrderIds(), isNot(contains('1002'))); + }); + + test('yesterday’s skip is not today’s work', () async { + // Pruned on read, like every other record in this store: a skip the + // rider can no longer act on must not hold a stop off tomorrow's tab. + await addCompletedBookings([stop('1001')], terminalStatus: 'skipped'); + final aged = (await getCompletedBookings()) + .map((b) => {...b, 'completedday': '2020-01-01'}) + .toList(); + await debugSeedStore('completed_bookings', jsonEncode(aged)); + + expect(await getCompletedOrderIds(), isEmpty); + }); + + test('another rider’s skip is not this one’s', () async { + await addCompletedBookings([stop('1001')], terminalStatus: 'skipped'); + SharedPreferences.setMockInitialValues({'userid': 99}); + expect(await getCompletedOrderIds(), isEmpty); + }); + }); +} diff --git a/test/skipped_store_test.dart b/test/skipped_store_test.dart index a77ba90..57a6fa7 100644 --- a/test/skipped_store_test.dart +++ b/test/skipped_store_test.dart @@ -18,28 +18,30 @@ import 'package:miler/data/accepted_store.dart'; /// • it forgets yesterday, or the tab grows without bound. void main() { Map booking(String id) => { - 'orderid': id, - 'pickupid': '9$id', - 'pickupcustomer': 'Sri Balaji Stores', - 'orderstatus': 'active', - }; + 'orderid': id, + 'pickupid': '9$id', + 'pickupcustomer': 'Sri Balaji Stores', + 'orderstatus': 'active', + }; setUp(() => SharedPreferences.setMockInitialValues({})); - test('a skip is recorded with its reason and reads back as skipped', - () async { - await addSkippedBooking(booking('A'), reason: 'Customer unreachable'); + test( + 'a skip is recorded with its reason and reads back as skipped', + () async { + await addSkippedBooking(booking('A'), reason: 'Customer unreachable'); - final stored = await getSkippedBookings(); - expect(stored, hasLength(1)); - expect(stored.single['skipreason'], 'Customer unreachable'); - expect( - stopStatusOf(stored.single), - StopStatus.skipped, - reason: 'the stamp is what lets one status test serve every screen', - ); - expect(await getSkippedOrderIds(), {'A'}); - }); + final stored = await getSkippedBookings(); + expect(stored, hasLength(1)); + expect(stored.single['skipreason'], 'Customer unreachable'); + expect( + stopStatusOf(stored.single), + StopStatus.skipped, + reason: 'the stamp is what lets one status test serve every screen', + ); + expect(await getSkippedOrderIds(), {'A'}); + }, + ); test('the same stop skipped twice is one row, not two', () async { await addSkippedBooking(booking('A'), reason: 'Customer unreachable'); @@ -66,15 +68,17 @@ void main() { ..['skipreason'] = 'Customer unreachable' ..['skippedat'] = '2020-01-01T09:00:00.000' ..['skippedday'] = '2020-01-01'; - SharedPreferences.setMockInitialValues({ - 'skipped_bookings': jsonEncode([stale]), - }); + SharedPreferences.setMockInitialValues({}); + await debugSeedStore('skipped_bookings', jsonEncode([stale])); expect(await getSkippedBookings(), isEmpty); // And the prune is persisted, so the row cannot come back. final prefs = await SharedPreferences.getInstance(); - expect(jsonDecode(prefs.getString('skipped_bookings')!), isEmpty); + expect( + jsonDecode(prefs.getString(await debugStoreKey('skipped_bookings'))!), + isEmpty, + ); }); test('newest first — the stop he just put down leads the list', () async { diff --git a/test/start_delivery_test.dart b/test/start_delivery_test.dart new file mode 100644 index 0000000..c03c57b --- /dev/null +++ b/test/start_delivery_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_domain.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PICKED IS NOT OUT FOR DELIVERY +/// +/// ── The regression ── +/// +/// The release used to ride along with `pickup-complete`: the rider slid to +/// confirm five bags at a counter and the same gesture posted all five +/// consignments `Out_for_Delivery`. The hub's board then showed five **active** +/// deliveries for food still on the shelf in front of him, and the day had no +/// moment left in it meaning "I have set off" — picked and out-for-delivery had +/// been collapsed into one claim. +/// +/// They are two claims about where the load is, made an hour apart, and only +/// the rider can make the second one. The ladder the hub sees is now: +/// +/// ``` +/// pickup-complete ──▶ Converted_To_Consignment admin: picked +/// START DELIVERY ──▶ Out_for_Delivery admin: active +/// deliver ──▶ Delivered admin: delivered +/// ``` +/// +/// These are the pure halves of that — the rung ladder and the stores it reads. +/// The press itself lives in `MyPickups.startRound`. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ServiceProfile.setActive(ServiceProfile.milkMan); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map order(String id, {String status = 'picked'}) => { + 'orderid': id, + 'pickupid': int.parse(id), + 'sourceid': 'K1', + 'sourcename': 'Vidhya Kitchen', + 'orderstatus': status, + 'pickuplat': 11.01, + 'pickuplon': 76.95, + 'droplat': 11.05, + 'droplon': 76.99, + }; + + group('after the counter, before the road', () { + test('a collected order is PICKED, not the delivery-active rung', () async { + await addCollectedOrderIds(['1001']); + + expect( + MilkRun.stageOf( + order('1001'), + acceptedIds: {'1001'}, + collectedIds: await getCollectedOrderIds(), + outForDeliveryIds: await getOutForDeliveryOrderIds(), + ), + StopStatus.picked, + ); + }); + + test('collecting does not release the round', () async { + // The whole regression in one line: nothing on the pickup path may write + // the released set. Only `startRound` does, and only after the server has + // agreed. + await addCollectedOrderIds(['1001', '1002']); + expect(await getOutForDeliveryOrderIds(), isEmpty); + }); + + test('an unreleased order is still on the deliveries tab', () async { + // The gate for the tab is the *collected* set, never the released one. + // Withholding uncollected-but-unreleased orders put them on no tab at + // all: dropped from Home as collected, hidden here as unstarted. + await addCollectedOrderIds(['1001']); + final queue = WorkBoundary.deliveryQueue( + [order('1001')], + collectedIds: await getCollectedOrderIds(), + acceptedIds: {'1001'}, + ); + expect(queue.map((s) => s['orderid']), ['1001']); + }); + }); + + group('the press', () { + test('releasing the load moves it to the delivery-active rung', () async { + await addCollectedOrderIds(['1001', '1002']); + // What `startRound` writes once the server has accepted the batch. + await addOutForDeliveryOrderIds(['1001', '1002']); + + for (final id in ['1001', '1002']) { + expect( + MilkRun.stageOf( + order(id), + acceptedIds: {id}, + collectedIds: await getCollectedOrderIds(), + outForDeliveryIds: await getOutForDeliveryOrderIds(), + ), + StopStatus.outForDelivery, + reason: '$id should be active once the round has started', + ); + } + }); + + test('it survives a restart, because it is on the device', () async { + await addOutForDeliveryOrderIds(['1001']); + // A fresh read of the store is what the screen does on every fetch. + expect(await getOutForDeliveryOrderIds(), contains('1001')); + }); + + test('completing a stop clears it, so tomorrow starts closed', () async { + await addOutForDeliveryOrderIds(['1001', '1002']); + await removeOutForDeliveryOrderIds(['1001']); + expect(await getOutForDeliveryOrderIds(), {'1002'}); + }); + }); + + group('the other line', () { + test('a logistics parcel never has a round to start', () { + ServiceProfile.setActive(ServiceProfile.parcel); + // Its collected parcel goes to a hub and somebody else delivers it, so + // the rider must never be shown a control claiming he has set off. + expect(ServiceProfile.active.deliversToCustomer, isFalse); + expect( + MilkRun.navigatesToCustomer(order('1001'), collectedIds: {'1001'}), + isFalse, + ); + }); + }); +} diff --git a/test/state_contract_test.dart b/test/state_contract_test.dart new file mode 100644 index 0000000..8dfb7a9 --- /dev/null +++ b/test/state_contract_test.dart @@ -0,0 +1,318 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/api_status.dart'; +import 'package:miler/data/consignment_state.dart'; +import 'package:miler/data/lifecycle.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RIDER'S RUNG AND THE OFFICE'S RUNG ARE THE SAME RUNG +/// +/// ── The regression, measured against production 21 Aug 2026 ── +/// +/// ``` +/// BEFORE GET /miler/bookings booking 78 = Miler_Assigned +/// CALL POST /miler/bookings/78/reached +/// → 200 {"success":true,"data":{"bookingid":78, +/// "status":"Miler_Assigned"}} +/// AFTER GET /miler/bookings booking 78 = Miler_Assigned +/// ``` +/// +/// `reached` reports success and writes nothing. The app read only `ok`, drew +/// ARRIVED, and the hub never heard about it — so a rider stood at a kitchen +/// with a screen saying one thing and an operator watching a board that said +/// another, and neither could tell the other was wrong. +/// +/// The rule these pin: **a 200 proves the call was accepted, never that the +/// state moved.** The only proof of a state change is the state in the +/// response. Every rung the app draws is now read that way, which is also what +/// makes the app correct under both of the backend's two live pickup +/// lifecycles without knowing which one is switched on. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List sent; + + http.Response jsonRes(Object body, [int code = 200]) => http.Response( + json.encode(body), + code, + headers: const {'content-type': 'application/json'}, + ); + + void stub(Map routes) { + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + for (final e in routes.entries) { + if (req.url.path.endsWith(e.key)) return e.value; + } + return jsonRes({'success': true}); + }); + } + + setUp(() async { + MockBackend.enabled = false; + SharedPreferences.setMockInitialValues({'authtoken': 't'}); + await ApiConfig.setToken('t'); + stub(const {}); + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + }); + + group('reached — arrival is only arrival if the server says so', () { + test('the production no-op is reported, not believed', () async { + // The exact body production returns today. + stub({ + '/reached': jsonRes({ + 'success': true, + 'data': {'bookingid': 78, 'status': 'Miler_Assigned'}, + }), + }); + + final t = MilerLifecycle.reached(await MilerApi.reached(78)); + + expect( + t.isConfirmed, + isFalse, + reason: + 'a 200 that leaves the booking on its old rung is not an ' + 'arrival, however cheerful the envelope is', + ); + expect(t.isUnconfirmed, isTrue); + expect(t.bookingStatus, BookingStatus.milerAssigned); + expect( + t.evidence, + contains('Miler_Assigned'), + reason: 'the log line has to be pasteable into a backend ticket', + ); + }); + + test('Arrived_At_Pickup is what confirms it', () async { + stub({ + '/reached': jsonRes({ + 'success': true, + 'data': {'bookingid': 78, 'status': 'Arrived_At_Pickup'}, + }), + }); + + final t = MilerLifecycle.reached(await MilerApi.reached(78)); + expect(t.isConfirmed, isTrue); + expect(t.bookingStatus, BookingStatus.arrivedAtPickup); + expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived'); + }); + + test('a response naming no status at all is not confirmation', () async { + stub({ + '/reached': jsonRes({'success': true, 'data': {}}), + }); + final t = MilerLifecycle.reached(await MilerApi.reached(78)); + expect(t.isUnconfirmed, isTrue); + expect(t.evidence, contains('no status')); + }); + + test('a refusal is a refusal, with its code', () async { + stub({ + '/reached': jsonRes({ + 'success': false, + 'code': 'INVALID_STATE', + 'message': 'not assigned to you', + }, 400), + }); + final t = MilerLifecycle.reached(await MilerApi.reached(78)); + expect(t.isRefused, isTrue); + expect(t.code, 'INVALID_STATE'); + }); + }); + + group('pickup-complete — both live lifecycles, read from the response', () { + test('the new flow stops at Collected_By_Miler', () async { + stub({ + '/pickup-complete': jsonRes({ + 'success': true, + 'data': { + 'booking_status': 'Converted_To_Consignment', + 'consignment_id': 46, + 'consignmentstatus': 'Collected_By_Miler', + 'next_action': 'start_delivery', + }, + }), + }); + + final t = MilerLifecycle.pickupComplete( + await MilerApi.pickupComplete(75), + ); + + expect(t.isConfirmed, isTrue); + expect(t.consignmentState, ConsignmentState.collectedByMiler); + expect(t.consignmentId, '46'); + expect(t.awaitsStartDelivery, isTrue); + expect( + t.isCompatibilityMode, + isFalse, + reason: 'the rider holds it — Start delivery is what makes it active', + ); + // The rung the rider and the office both see: collected, not delivering. + expect( + ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'), + 'picked', + ); + }); + + test('compatibility mode is detected, not assumed', () async { + // What production returns today, with the server flag off. + stub({ + '/pickup-complete': jsonRes({ + 'success': true, + 'data': { + 'booking_status': 'Converted_To_Consignment', + 'consignment_id': 46, + 'consignmentstatus': 'Out_for_Delivery', + }, + }), + }); + + final t = MilerLifecycle.pickupComplete( + await MilerApi.pickupComplete(75), + ); + + expect(t.isConfirmed, isTrue); + expect(t.consignmentState, ConsignmentState.outForDelivery); + expect( + t.isCompatibilityMode, + isTrue, + reason: + 'this is the fact the app must surface rather than paper over ' + '— it is why the console shows Active for a fresh pickup', + ); + expect(t.awaitsStartDelivery, isFalse); + // And the app must agree with the console rather than say "picked". + expect( + ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'), + 'outfordelivery', + ); + }); + + test('no feature flag is mirrored — the same build reads both', () async { + for (final (status, expected) in const [ + ('Collected_By_Miler', ConsignmentState.collectedByMiler), + ('Out_for_Delivery', ConsignmentState.outForDelivery), + ]) { + stub({ + '/pickup-complete': jsonRes({ + 'success': true, + 'data': {'consignment_id': 46, 'consignmentstatus': status}, + }), + }); + final t = MilerLifecycle.pickupComplete( + await MilerApi.pickupComplete(75), + ); + expect(t.consignmentState, expected); + } + }); + + test('next_action alone is enough to place the new flow', () async { + stub({ + '/pickup-complete': jsonRes({ + 'success': true, + 'data': {'consignment_id': 46, 'next_action': 'start_delivery'}, + }), + }); + final t = MilerLifecycle.pickupComplete( + await MilerApi.pickupComplete(75), + ); + expect(t.consignmentState, ConsignmentState.collectedByMiler); + expect(t.awaitsStartDelivery, isTrue); + }); + + test('a pivot that minted no consignment is not confirmed', () async { + stub({ + '/pickup-complete': jsonRes({'success': true, 'data': {}}), + }); + final t = MilerLifecycle.pickupComplete( + await MilerApi.pickupComplete(75), + ); + expect( + t.isConfirmed, + isFalse, + reason: + 'without an id there is nothing to deliver against, whatever ' + 'the words say', + ); + }); + }); + + group('the ladder, rung by rung', () { + test('Accepted is where the rider starts', () { + expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted'); + expect(BookingStatus.parse('Pickup_Scheduled').isOpen, isTrue); + expect(BookingStatus.parse('Pickup_Scheduled').isCollected, isFalse); + }); + + test('Arrived does not move the work boundary', () { + expect(BookingStatus.arrivedAtPickup.isCollected, isFalse); + expect(BookingStatus.arrivedAtPickup.isOpen, isTrue); + }); + + test('start-delivery is only for a collected consignment', () { + expect(ConsignmentState.collectedByMiler.needsRelease, isTrue); + expect( + ConsignmentState.outForDelivery.needsRelease, + isFalse, + reason: + 'calling start-delivery on an already-released consignment is ' + 'a 4xx per press in the backend log', + ); + expect(ConsignmentState.inwardedAtHub.needsRelease, isFalse); + }); + + test('only Out_for_Delivery is deliverable', () { + expect(ConsignmentState.outForDelivery.isDeliverable, isTrue); + expect(ConsignmentState.collectedByMiler.isDeliverable, isFalse); + expect(ConsignmentState.inwardedAtHub.isDeliverable, isFalse); + expect( + ConsignmentGate.gateFor(ConsignmentState.collectedByMiler), + DeliverGate.needsRelease, + ); + expect( + ConsignmentGate.gateFor(ConsignmentState.outForDelivery), + DeliverGate.deliverable, + ); + }); + + test('Delivered is terminal and reached only through deliver', () { + expect(ConsignmentState.delivered.isDelivered, isTrue); + expect(ConsignmentState.delivered.isDeliverable, isFalse); + expect( + ConsignmentGate.gateFor(ConsignmentState.delivered), + DeliverGate.alreadyDelivered, + ); + }); + + test('the whole ladder maps to rider words with no enum leaking', () { + // What the rider sees at each rung. Never the API's own spelling. + expect(ApiConfig.legacyStatusFromNew('Miler_Assigned'), 'assigned'); + expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted'); + expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived'); + expect( + ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'), + 'picked', + ); + expect( + ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'), + 'outfordelivery', + ); + expect(ApiConfig.legacyStatusFromConsignment('Delivered'), 'delivered'); + }); + }); +} diff --git a/test/status_contract_test.dart b/test/status_contract_test.dart new file mode 100644 index 0000000..8c31b25 --- /dev/null +++ b/test/status_contract_test.dart @@ -0,0 +1,318 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/api_status.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/assignment_lookup.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_domain.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE BACKEND CONTRACT, AS THIS APP READS IT +/// +/// Every status `GET /miler/bookings` can carry, taken from the API reference +/// and the flow doc in the console repo rather than from what the app happened +/// to have seen. The list is the contract; the assertions are this app's +/// reading of it. +/// +/// Three defects these pin: +/// +/// • **`Out_for_Delivery` was not in [BookingStatus].** `pickup-complete` +/// releases a hyperlocal booking straight to it — which is every DailyGrubs +/// run — so a bag already in the rider's box parsed as `unknown`, translated +/// to the empty string, and read as *undecided*. The local collected record +/// hid it on the phone that did the pickup and nowhere else: after a restart +/// or on a second device, collected work sat on Home as pending. +/// • **An unmapped status write was reported as success.** Nothing left the +/// phone and the caller was told it had landed. +/// • **The hub's solved stop order was discarded.** `step` lives on the +/// assignment row; the app read those rows for their ids and dropped it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// Exactly the strings the backend sends, in the backend's own casing — + /// including `Out_for_Delivery`, whose lower-case `f` differs from the + /// consignment enum's `Out_For_Delivery`. + const backendBookingStatuses = [ + 'Created', + 'Pending_Pickup', + 'Miler_Assigned', + 'Pickup_Scheduled', + 'Picked_Up', + 'Converted_To_Consignment', + 'Out_for_Delivery', + 'Delivered', + 'Cancelled', + ]; + + group('every status the backend sends is typed', () { + test('none of them fall through to unknown', () { + for (final raw in backendBookingStatuses) { + expect( + BookingStatus.parse(raw), + isNot(BookingStatus.unknown), + reason: '$raw is in the contract and must parse', + ); + } + }); + + test('and each one survives the translation to the row vocabulary', () { + // `legacyStatusFromNew` is the seam between the v1 enum and the lowercase + // vocabulary every row, card and filter in this app reads. A status that + // parses but translates to something `stopStatusFromRaw` cannot read is + // the same bug one layer further on. + for (final raw in backendBookingStatuses) { + final legacy = ApiConfig.legacyStatusFromNew(raw); + expect( + stopStatusFromRaw(legacy), + isNot(StopStatus.unknown), + reason: '$raw translated to "$legacy", which the app cannot read', + ); + } + }); + + test('both casings of out-for-delivery land on the same rung', () { + expect( + BookingStatus.parse('Out_for_Delivery'), + BookingStatus.outForDelivery, + ); + expect( + BookingStatus.parse('Out_For_Delivery'), + BookingStatus.outForDelivery, + ); + }); + }); + + group('collected is a fact about the backend, not about this device', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + test( + 'a released hyperlocal booking is delivery work with no local record', + () { + // The restart case: `collectedIds` is empty because this install never + // performed the pickup. The status alone has to carry it. + final stop = { + 'orderid': 'A1', + 'type': 'pickup', + 'orderstatus': ApiConfig.legacyStatusFromNew('Out_for_Delivery'), + }; + + expect(WorkBoundary.pickupComplete(stop), isTrue); + expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); + }, + ); + + test('and a delivered one is finished, not pending', () { + final stop = { + 'orderid': 'A1', + 'type': 'pickup', + 'orderstatus': ApiConfig.legacyStatusFromNew('Delivered'), + }; + + expect(WorkBoundary.domainOf(stop), WorkDomain.closed); + }); + + test('an assignment the rider has not acted on is still Home\'s', () { + for (final raw in ['Created', 'Pending_Pickup', 'Miler_Assigned']) { + final stop = { + 'orderid': 'A1', + 'orderstatus': ApiConfig.legacyStatusFromNew(raw), + }; + expect(WorkBoundary.domainOf(stop), WorkDomain.pickup, reason: raw); + } + }); + }); + + group('the rungs come in order', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + + test('accepting does not skip the arrival rung', () { + // The reported bug: accept a stop, select it again, and the bar offered + // **Mark as Picked** for a kitchen the rider had not reached. + // + // `Pickup_Scheduled` is what the backend writes on accept, and it was + // translated to `active` — which `stopStateOf` reads as *the rider is on + // the stop*, ahead of the local accepted record. So the queue's own + // answer promoted every accepted stop straight past arrival, and + // `reached` — the one rung with a real endpoint behind it — could never + // be sent. + final stop = { + 'orderid': 'A1', + 'orderstatus': ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), + }; + + expect( + stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}), + StopState.accepted, + reason: 'accepted, not on the stop', + ); + }); + + test('and arriving is what puts him on the stop', () { + final stop = {'orderid': 'A1', 'orderstatus': 'arrived'}; + expect( + stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}), + StopState.active, + ); + }); + + test('the local accept record reads the same rung as the server\'s', () { + // Optimistic accept, before the queue has caught up: both paths must + // land on the same rung or the bar changes its offer under him. + final stop = {'orderid': 'A1', 'orderstatus': 'assigned'}; + expect( + stopStateOf(stop, acceptedIds: const {'A1'}, rejectedIds: const {}), + StopState.accepted, + ); + }); + }); + + group('an unknown status is never a settled one', () { + test('a future booking status reads as undecided, not as finished', () { + const future = 'Rescheduled_By_Hub'; + expect(BookingStatus.parse(future), BookingStatus.unknown); + expect(BookingStatus.parse(future).isCollected, isFalse); + + final stop = { + 'orderid': 'A1', + 'orderstatus': ApiConfig.legacyStatusFromNew(future), + }; + ServiceProfile.setActive(ServiceProfile.milkMan); + expect( + WorkBoundary.domainOf(stop), + WorkDomain.pickup, + reason: 'wrong-but-safe: it stays where the rider can still act on it', + ); + expect(WorkBoundary.isClosed(stop), isFalse); + }); + + test('a future assignment status is not settled either', () { + expect(AssignmentStatus.parse('Escalated'), AssignmentStatus.unknown); + expect(AssignmentStatus.unknown.isSettled, isFalse); + expect(AssignmentStatus.unknown.isLive, isFalse); + expect(AssignmentStatus.unknown.needsDecision, isFalse); + }); + + test('a future consignment status is not delivered', () { + expect( + ConsignmentStatus.parse('Held_At_Customs'), + ConsignmentStatus.unknown, + ); + expect(ConsignmentStatus.unknown.isDelivered, isFalse); + }); + }); + + group('a write this app cannot map is a failure, not a success', () { + test('the unmapped branch reports failure and sends nothing', () async { + // No network stub: reaching one would itself be the bug. The branch must + // refuse before any request is built. + final res = await UpdatePickupProvider().updatePickup({ + 'pickupid': 1, + 'orderstatus': 'teleported', + }); + + expect(res, isNotNull); + expect(res!['status'], isFalse); + expect(res['message'], contains('teleported')); + }); + }); + + group('the consignment id survives the pivot', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('it is recorded at pickup-complete and read back at the door', () async { + // The reported failure: press **Delivered** at a customer's door and the + // app answers "could not update — check your connection", with no request + // ever made. + // + // `deliver` and `skip` key on the CONSIGNMENT, which only exists after + // `pickup-complete` creates it. That response was wrapped and discarded, + // and the row the rider works from on Deliveries is a snapshot taken + // *before* the conversion — so nothing on the device knew what to + // deliver, and the refusal came from the app, not the network. + await rememberConsignmentId('A1', 'CN-9001'); + + expect((await getConsignmentIds())['A1'], 'CN-9001'); + }); + + test('and is dropped once the stop is finished', () async { + await rememberConsignmentId('A1', 'CN-9001'); + await forgetConsignmentIds(['A1']); + + expect((await getConsignmentIds()).containsKey('A1'), isFalse); + }); + + test('an empty id is not worth recording', () async { + await rememberConsignmentId('A1', ''); + await rememberConsignmentId('', 'CN-9001'); + + expect(await getConsignmentIds(), isEmpty); + }); + }); + + group('the hub\'s solved stop order is kept', () { + test('step is read from the assignment row', () { + final index = AssignmentLookup.buildStepIndex([ + { + 'bookingid': 118, + 'bookingassignmentid': 1, + 'step': 2, + 'assignmentstatus': 'Accepted', + }, + { + 'bookingid': 119, + 'bookingassignmentid': 2, + 'step': 1, + 'assignmentstatus': 'Assigned', + }, + ]); + + expect(index['118'], 2); + expect(index['119'], 1); + }); + + test('step 0 means unsequenced and is not stored as a position', () { + // `step: 0` sorts to the end in `Trip.sortStops`; storing it would make an + // unsequenced stop look like it had been solved into first place. + final index = AssignmentLookup.buildStepIndex([ + { + 'bookingid': 120, + 'bookingassignmentid': 3, + 'step': 0, + 'assignmentstatus': 'Assigned', + }, + ]); + + expect(index.containsKey('120'), isFalse); + }); + + test('a reassigned booking takes the live assignment\'s sequence', () { + // Same rule as the id index: newest first, but an actionable row beats a + // newer terminal one, so the two indexes cannot describe different rows. + final rows = [ + { + 'bookingid': 118, + 'bookingassignmentid': 9, + 'step': 5, + 'assignmentstatus': 'Rejected', + }, + { + 'bookingid': 118, + 'bookingassignmentid': 10, + 'step': 3, + 'assignmentstatus': 'Accepted', + }, + ]; + + expect(AssignmentLookup.buildStepIndex(rows)['118'], 3); + expect(AssignmentLookup.buildIndex(rows)['118'], 10); + }); + }); +} diff --git a/test/stop_area_test.dart b/test/stop_area_test.dart new file mode 100644 index 0000000..ff9f749 --- /dev/null +++ b/test/stop_area_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/stop_area.dart'; + +/// ── The rule, pinned against the addresses that broke it ── +/// +/// `areaOf` has been wrong three times, each on a real address from this +/// tenant, and each fix was verified on a device rather than on a guess. The +/// cases below are those addresses. A change that shortens or "simplifies" +/// the rule must survive every one of them. +void main() { + String area(String addr) => areaOf({'dropaddress': addr}); + + test('a door number does not take its locality with it', () { + // The v2 rule dropped any part containing a digit, so this answered with + // the city — and on a Coimbatore route every stop is in Coimbatore, which + // is a queue that says nothing. The number is stripped, the name kept. + expect(area('12 Peelamedu, Coimbatore'), 'Peelamedu'); + }); + + test('the classic comma form still yields the locality', () { + expect(area('12, SNS Colony, Peelamedu, Coimbatore 641004'), 'Peelamedu'); + }); + + test('a landmark phrase never wins over the name behind it', () { + // Seen live: the first digit-free part was this whole landmark, printed + // on every row of the run, wrapped to two lines. + expect( + area( + 'Coimbatore Gandhipuram Mofussil Bus Stand, Sathyamurthi Road, ' + 'Gandhipuram, Ward 54, West Zone', + ), + 'Gandhipuram', + ); + }); + + test('a street with a door number is passed over for the area', () { + expect( + area('14 Cross Cut Road, Gandhipuram, Coimbatore 641012'), + 'Gandhipuram', + ); + expect(area('12, Cross Cut Road, Gandhipuram'), 'Gandhipuram'); + }); + + test('a stammered locality is one locality', () { + // Live: the geocoder writes `Gandhipuram Gandhipuram` as one part, and + // the queue printed the stutter on every row it headed. + expect(area('12, Gandhipuram Gandhipuram, Coimbatore'), 'Gandhipuram'); + expect(area('4 Cross, RS Puram RS Puram, Coimbatore'), 'RS Puram'); + }); + + test('Ward 54 is not a place called Ward', () { + // Stripping digits can leave administrative residue; it must be discarded, + // not promoted to a one-word name. + expect(area('Ward 54, Peelamedu, Coimbatore'), 'Peelamedu'); + }); + + test('with no one-word locality, the street beats the city', () { + // "Race Course" is the locality's actual name here; the city rung would + // distinguish nothing on a route where every stop ends in Coimbatore. + expect(area('22 Race Course Road, Coimbatore'), 'Race Course Road'); + // But a bare city with nothing before it is still an honest answer. + expect(area('Coimbatore'), 'Coimbatore'); + }); + + test('an empty address returns nothing, never a placeholder', () { + expect(area(''), ''); + expect(areaOf(const {}), ''); + }); + + group('compactAddress', () { + test('geocoder boilerplate comes off; every place stays', () { + expect( + compactAddress( + 'RS Puram RS Puram, Ward 24, West Zone, Perur, Coimbatore South, ' + 'Coimbatore, Tamil Nadu, 641002, India', + ), + 'RS Puram, Ward 24, West Zone, Perur, Coimbatore South, Coimbatore', + ); + }); + + test('a hand-typed address passes through untouched', () { + expect( + compactAddress('12, SNS Colony, Peelamedu, Coimbatore 641004'), + '12, SNS Colony, Peelamedu, Coimbatore 641004', + ); + }); + + test('an immediate duplicate part collapses', () { + expect( + compactAddress('Gandhipuram, Gandhipuram, Coimbatore'), + 'Gandhipuram, Coimbatore', + ); + }); + }); + + test('a delivery leg prefers the drop; a pickup leg the pickup', () { + final stop = { + 'pickupaddress': '1 Anna Nagar, Coimbatore', + 'dropaddress': '2 Saibaba Colony, Coimbatore', + }; + expect(areaOf(stop), 'Saibaba Colony'); + expect(areaOf(stop, preferDrop: false), 'Anna Nagar'); + }); +} diff --git a/test/stop_card_neutral_test.dart b/test/stop_card_neutral_test.dart index 0f7dfee..f976673 100644 --- a/test/stop_card_neutral_test.dart +++ b/test/stop_card_neutral_test.dart @@ -36,22 +36,24 @@ void main() { double cash = 0, int deliverQty = 0, int pickupQty = 0, - }) => - { - 'orderid': id, - 'type': type, - 'pickupcustomer': 'Sri Balaji Stores', - 'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore', - 'pickupcontactno': '9876543210', - if (deliverQty > 0) 'deliveryqty': '$deliverQty', - if (pickupQty > 0) 'pickupqty': '$pickupQty', - 'Quantity': '3', - if (cash > 0) 'collectionamt': '$cash', - 'expected_pickup_time': DateTime.now().toIso8601String(), - }; + }) => { + 'orderid': id, + 'type': type, + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore', + 'pickupcontactno': '9876543210', + if (deliverQty > 0) 'deliveryqty': '$deliverQty', + if (pickupQty > 0) 'pickupqty': '$pickupQty', + 'Quantity': '3', + if (cash > 0) 'collectionamt': '$cash', + 'expected_pickup_time': DateTime.now().toIso8601String(), + }; - Future pumpCard(WidgetTester tester, - {Set accepted = const {}, double width = 390}) async { + Future pumpCard( + WidgetTester tester, { + Set accepted = const {}, + double width = 390, + }) async { tester.view.physicalSize = Size(width * 3, 2532); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); @@ -93,7 +95,8 @@ void main() { void addBorder(String where, BoxBorder? b) { if (b is Border) { for (final side in [b.top, b.right, b.bottom, b.left]) { - if (side.style != BorderStyle.none) found.add(('$where.border', side.color)); + if (side.style != BorderStyle.none) + found.add(('$where.border', side.color)); } } } @@ -150,17 +153,22 @@ void main() { 'combinedAccent (deep indigo)': ColorConstants.combinedAccent, }; - testWidgets('the un-darkened type accents are not painted at all', - (tester) async { + testWidgets('the un-darkened type accents are not painted at all', ( + tester, + ) async { await pumpCard(tester); final painted = paintedColours(tester); expect(painted, isNotEmpty, reason: 'sanity: the card painted something'); for (final entry in bannedOutright.entries) { final hits = painted.where((p) => p.$2 == entry.value).toList(); - expect(hits, isEmpty, - reason: '${entry.key} is the light accent — the chip should use the ' - 'darkened variant. Found at ${hits.map((h) => h.$1).toSet()}'); + expect( + hits, + isEmpty, + reason: + '${entry.key} is the light accent — the chip should use the ' + 'darkened variant. Found at ${hits.map((h) => h.$1).toSet()}', + ); } }); @@ -168,90 +176,100 @@ void main() { await pumpCard(tester, accepted: {'D', 'C'}); final painted = paintedColours(tester); for (final entry in bannedOutright.entries) { - expect(painted.where((p) => p.$2 == entry.value), isEmpty, - reason: '${entry.key} must not appear in the accepted state either'); + expect( + painted.where((p) => p.$2 == entry.value), + isEmpty, + reason: '${entry.key} must not appear in the accepted state either', + ); } }); - testWidgets('type colour reaches the chip and nothing else', (tester) async { + // ── Where the type went, and why these two tests changed ── + // + // The rule this file was written for was "a stop type may be a mark, never a + // surface", and it was measured by finding the type *word* on the row and + // checking nothing was filled with its colour. + // + // The route timeline then took the word off the row as well. That was not a + // slip: a step is `who` and `which bag`, the two facts a rider matches + // against a shelf, and the type went into the detail sheet with the address, + // the phone and the parcel counts. So the rule gets **stricter** rather than + // looser — a stop type may not tint anything on the route, and it may not + // spend a line there either — and that is what is measured now. + // + // The wording itself is not lost and is not untested: [StopKind.badgeLabel] + // and [StopKind.chipLabel] still carry it, the Bookings card still prints it + // on a line of its own, and `service_card_test.dart` holds both. + + testWidgets('a stop type tints nothing on the route', (tester) async { await pumpCard(tester); - // Present: the three darkened chip fills, one per stop kind. - final containerFills = tester.allWidgets + final containerColours = tester.allWidgets .whereType() .map((c) => c.decoration) .whereType() - .map((d) => d.color) + .expand( + (d) => [ + d.color, + if (d.border is Border) (d.border as Border).top.color, + ], + ) .whereType() .toList(); - for (final chip in [ - ColorConstants.pickupChip, - ColorConstants.deliveryChip, - ColorConstants.combinedChip, - ]) { - expect(containerFills, contains(chip), - reason: 'the type chip should be filled with its darkened accent'); - } - - // Absent: any of those three used as a *border*. A chip is a mark; a border - // belongs to a surface, and that is the line being held. - final borderColours = tester.allWidgets - .whereType() - .map((c) => c.decoration) - .whereType() - .map((d) => d.border) - .whereType() - .expand((b) => [b.top.color, b.right.color, b.bottom.color, b.left.color]) - .toList(); + final textColours = tester.allWidgets + .whereType() + .map((t) => t.style?.color) + .whereType() + .toSet(); for (final chip in [ ColorConstants.pickupChip, ColorConstants.deliveryChip, ColorConstants.combinedChip, ]) { - expect(borderColours, isNot(contains(chip)), - reason: 'stop type must not outline a surface'); + expect( + containerColours.contains(chip), + isFalse, + reason: 'a stop type may not fill or border anything on the route', + ); + expect( + textColours.contains(chip), + isFalse, + reason: + 'nor tint type: the route is one accent — the brand, on the live ' + 'node and the journey action — and a per-stop hue competes with it', + ); } }); - testWidgets('stop cards are borderless, on one fill whatever the type', - (tester) async { + testWidgets('a step states who it is for, and no stop type', (tester) async { await pumpCard(tester); - // The stop bodies: a rounded fill with no border at all now. - final bodies = tester.allWidgets - .whereType() - .map((c) => c.decoration) - .whereType() - .where((d) => d.color == ColorConstants.cardSurface) - .toList(); + // This is a parcel route, so each stop is its own node and what names it is + // the customer plus its position in the run. (The `who` + `which bag` pair + // is the kitchen-group shape and is held by `one_bag_per_order_test.dart`.) + expect(find.text('Sri Balaji Stores'), findsWidgets); + expect(find.text('Stop 1'), findsOneWidget); - expect(bodies.length, 3, reason: 'one fill per stop, all three identical'); - for (final d in bodies) { - expect(d.border, isNull, reason: 'the card carries no border'); - expect(d.boxShadow, anyOf(isNull, isEmpty), - reason: 'the fill does the separating, not an elevation'); + // Everything the card used to shout on every row. The type is one tap away + // in the sheet, on the badge `StopDetailSheet._header` draws. + for (final label in const [ + 'PICKUP', + 'DELIVERY', + 'P&D', + 'PICKUP & DELIVERY', + ]) { + expect( + find.text(label), + findsNothing, + reason: + '"$label" is on the stop sheet, not on a row a rider reads forty ' + 'times a shift', + ); } }); - testWidgets('type is stated in words, not colour', (tester) async { - await pumpCard(tester); - // The labels still say which kind of stop each one is — that information was - // never removed, only moved out of the fill and into the text. - // - // The combined chip reads "P&D", not "PICKUP & DELIVERY". On a stop row the - // chip shares a line with a duration, a call button and a tick box, all of - // which have a fixed claim on the width, so 17 characters ellipsised down to - // "P…" — a chip that has to truncate is not a chip. The full wording still - // appears on the Bookings card, which has a whole line for it. - expect(find.text('PICKUP'), findsWidgets); - expect(find.text('DELIVERY'), findsWidgets); - expect(find.text('P&D'), findsWidgets); - expect(find.text('PICKUP & DELIVERY'), findsNothing, - reason: 'the long label does not fit a stop-row chip'); - }); - testWidgets('the type chip is never squeezed out of shape', (tester) async { // The bug: the chip rendered as "P…". Its `Flexible` sat beside a `Spacer`, // and a Spacer is an `Expanded` — so the two split the free width evenly and @@ -269,8 +287,11 @@ void main() { for (final width in [320.0, 360.0, 390.0, 430.0]) { await pumpCard(tester, width: width); - expect(tester.takeException(), isNull, - reason: 'the meta row overflowed at ${width.toInt()}pt'); + expect( + tester.takeException(), + isNull, + reason: 'the meta row overflowed at ${width.toInt()}pt', + ); for (final label in ['PICKUP', 'DELIVERY', 'P&D']) { final finder = find.text(label); @@ -283,14 +304,17 @@ void main() { style: tester.widget(finder.first).style, ), textDirection: TextDirection.ltr, - )..layout()) - .width; + )..layout()).width; final got = box.size.width / natural; - expect(got, greaterThan(acceptable), - reason: 'at ${width.toInt()}pt the "$label" chip got ' - '${(got * 100).round()}% of the width its label needs; the ' - 'Spacer-stealing bug gave ${(squeezed * 100).round()}%'); + expect( + got, + greaterThan(acceptable), + reason: + 'at ${width.toInt()}pt the "$label" chip got ' + '${(got * 100).round()}% of the width its label needs; the ' + 'Spacer-stealing bug gave ${(squeezed * 100).round()}%', + ); } } }); diff --git a/test/stop_compliance_test.dart b/test/stop_compliance_test.dart index c61dee2..e026f73 100644 --- a/test/stop_compliance_test.dart +++ b/test/stop_compliance_test.dart @@ -27,8 +27,11 @@ void main() { const near = StopCompliance(actualKm: 6.2, plannedKm: 5.0); // +24% expect(near.onRoute, isTrue); const grace = StopCompliance(actualKm: 1.4, plannedKm: 1.0); // short hop - expect(grace.onRoute, isTrue, - reason: 'a fixed grace keeps short stops from failing on rounding'); + expect( + grace.onRoute, + isTrue, + reason: 'a fixed grace keeps short stops from failing on rounding', + ); }); test('late is not clean, however good the route', () { @@ -51,8 +54,11 @@ void main() { expect(nothing.isClean, isFalse); const zeroPlan = StopCompliance(actualKm: 3.0, plannedKm: 0); - expect(zeroPlan.onRoute, isNull, - reason: 'a planned distance of zero is missing, not perfect'); + expect( + zeroPlan.onRoute, + isNull, + reason: 'a planned distance of zero is missing, not perfect', + ); }); }); @@ -84,13 +90,13 @@ void main() { test('scores only what could be measured', () { final summary = ComplianceSummary.from([ { - 'compliance': {'ontime': true, 'actualkm': 4.0, 'plannedkm': 4.0} + 'compliance': {'ontime': true, 'actualkm': 4.0, 'plannedkm': 4.0}, }, { - 'compliance': {'ontime': false, 'actualkm': 4.0, 'plannedkm': 4.0} + 'compliance': {'ontime': false, 'actualkm': 4.0, 'plannedkm': 4.0}, }, { - 'compliance': {'ontime': true, 'actualkm': 20.0, 'plannedkm': 4.0} + 'compliance': {'ontime': true, 'actualkm': 20.0, 'plannedkm': 4.0}, }, {'orderid': 'no-data'}, ]); diff --git a/test/stop_map_contract_test.dart b/test/stop_map_contract_test.dart new file mode 100644 index 0000000..ba2a7df --- /dev/null +++ b/test/stop_map_contract_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/stop_contact.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE LIVE STOP SCREEN'S CONTRACT +/// +/// One screen serves both legs — a kitchen counter and a customer's door — and +/// almost everything on it changes meaning between them: the destination, the +/// copy, the action, and the number under the Call button. +/// +/// That last one is the dangerous one, because it fails silently. A rider at a +/// door taps Call, reaches the kitchen, and the screen looks correct +/// throughout. These pin who is being rung, per leg, and who the app *says* is +/// being rung — the two used to be decided in different places. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the contact belongs to the leg', () { + test('a delivery prefers the drop contact when one exists', () { + final contact = StopContact.forLeg(const { + 'pickupcontactno': '9000000001', + 'dropcontactno': '9000000002', + }, delivery: true); + + expect(contact.number, '9000000002'); + expect(contact.who, 'the customer'); + expect(contact.action, 'Call the customer'); + }); + + test('a pickup prefers the pickup contact', () { + final contact = StopContact.forLeg(const { + 'pickupcontactno': '9000000001', + 'dropcontactno': '9000000002', + }, delivery: false); + + expect(contact.number, '9000000001'); + expect(contact.who, 'the pickup'); + expect(contact.action, 'Call the pickup'); + }); + + test("today's payload carries one number, and both legs reach it", () { + // Verified against production 21 Aug 2026: `GET /miler/bookings` returns + // exactly one phone field, `customerphone`, which the adapter stores as + // `pickupcontactno`. There is no drop contact in the contract at all. + // + // So the number is the same on both legs and dialling it is correct + // either way — the defect was never the digits, it was the app calling + // it "the customer" while the rider stood at a kitchen counter. + const live = {'pickupcontactno': '9787698259'}; + expect(StopContact.forLeg(live, delivery: true).number, '9787698259'); + expect(StopContact.forLeg(live, delivery: false).number, '9787698259'); + expect( + StopContact.forLeg(live, delivery: true).who, + isNot(StopContact.forLeg(live, delivery: false).who), + reason: 'the label still has to tell the rider who answers', + ); + }); + + test('no number means no control, not a dead one', () { + final contact = StopContact.forLeg(const { + 'orderid': '1', + }, delivery: true); + expect(contact.isEmpty, isTrue); + expect(contact.number, ''); + }); + + test('a blank or placeholder number counts as none', () { + for (final junk in const ['', ' ', '0', 'null']) { + expect( + StopContact.forLeg({ + 'pickupcontactno': junk, + }, delivery: false).isEmpty, + isTrue, + reason: '"$junk" must not reach a dialer', + ); + } + }); + + test('the older spellings still resolve', () { + expect( + StopContact.forLeg(const { + 'PickupContactNo': '9000000003', + }, delivery: false).number, + '9000000003', + ); + expect( + StopContact.forLeg(const { + 'customerphone': '9000000004', + }, delivery: true).number, + '9000000004', + ); + }); + }); +} diff --git a/test/stop_row_alignment_test.dart b/test/stop_row_alignment_test.dart index c6f5267..51d8d0c 100644 --- a/test/stop_row_alignment_test.dart +++ b/test/stop_row_alignment_test.dart @@ -1,62 +1,83 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart'; -/// The call button and the tick box on a Home stop row must sit on the same two -/// columns for every stop in the route. +/// A control a rider reaches for without looking has to be in the same place on +/// every row of the route. /// -/// They did not. Both were *conditional* children of the meta row — -/// `if (phone.isNotEmpty)` and `if (pending && onToggleSelect != null)` — so -/// whenever one was missing the other slid across to take its place. A stop with -/// no phone number pushed its tick box a tap-target to the right; an -/// already-accepted stop pushed its call button the same way. Down a route of -/// mixed states the two controls zig-zagged, and a rider reaching for "the tick" -/// had to locate it again on every row. +/// ── What this used to measure, and why it moved ── +/// +/// The call button and the tick box on a Home stop *card* were conditional +/// children of one meta row — `if (phone.isNotEmpty)` and `if (pending && +/// onToggleSelect != null)` — so whenever one was missing the other slid across +/// to take its place. A stop with no phone number pushed its tick a tap-target +/// to the right; an already-decided stop pushed its call button the same way. +/// Down a route of mixed states the two zig-zagged, and a rider reaching for +/// "the tick" had to locate it again on every row. +/// +/// Both of those controls are gone with the cards. The tick *is* the row now, +/// and the phone moved into the detail sheet with the rest of the progressive +/// disclosure. So the principle is measured where it now lives: the timeline's +/// spine. Every node — a place, an order, whatever state it is in — sits on one +/// vertical axis, and every row's content starts on one left edge. That is a +/// stronger version of the same contract, because it holds across two kinds of +/// row rather than one, and it is what makes a run scannable in a glance. /// /// This is the kind of thing that is obvious in a screenshot and invisible in -/// code review, so it is measured: the x-positions are compared across a trip -/// deliberately built to hit every combination. +/// code review, so it is measured against a route deliberately built to mix +/// every state Home still lists. void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + Map stop( String id, { required String type, String? phone, - }) => - { - 'orderid': id, - 'type': type, - 'pickupcustomer': 'Sri Balaji Stores', - 'pickupaddress': '19, Thadagam Road, Coimbatore', - if (phone != null) 'pickupcontactno': phone, - 'Quantity': '2', - 'expected_pickup_time': DateTime.now().toIso8601String(), - }; + String? kitchen, + }) => { + 'orderid': id, + 'type': type, + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Coimbatore', + if (phone != null) 'pickupcontactno': phone, + if (kitchen != null) 'kitchenid': kitchen, + if (kitchen != null) 'kitchenname': kitchen, + 'Quantity': '2', + 'expected_pickup_time': DateTime.now().toIso8601String(), + }; /// A route covering every trailing-slot combination. /// - /// The states are the three Home still lists — pending, rejected, skipped. - /// Accepted stops used to fill the "no tick box" cases here, but they leave - /// Home for Bookings now, so a decided-but-still-listed state does the job: + /// The states are the three Home still lists — pending, rejected, skipped: /// - /// 1 pending + phone → both controls - /// 2 pending, no phone → tick only - /// 3 rejected + phone → call only (no tick: it is already decided) - /// 4 skipped, no phone → neither - Future pumpRoute(WidgetTester tester) async { + /// 1 pending + phone → tick, and a way into the sheet + /// 2 pending, no phone → the same, with nothing to call + /// 3 rejected + phone → no tick; it is already decided + /// 4 skipped, no phone → not listed at all + Future pumpRoute(WidgetTester tester, {String? kitchen}) async { tester.view.physicalSize = const Size(1170, 2532); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final trip = Trip.fromStops(id: 'mixed', stops: [ - stop('A', type: 'pickup', phone: '9876543210'), - stop('B', type: 'delivery'), - {...stop('C', type: 'combined', phone: '9876500000')}, - {...stop('D', type: 'pickup'), 'orderstatus': 'skipped'}, - ]); + final trip = Trip.fromStops( + id: 'mixed', + stops: [ + stop('A', type: 'pickup', phone: '9876543210', kitchen: kitchen), + stop('B', type: 'delivery', kitchen: kitchen), + stop('C', type: 'combined', phone: '9876500000', kitchen: kitchen), + { + ...stop('D', type: 'pickup', kitchen: kitchen), + 'orderstatus': 'skipped', + }, + ], + ); await tester.pumpWidget( ScreenUtilInit( @@ -69,6 +90,8 @@ void main() { rejectedIds: const {'C'}, onToggleSelect: (_) {}, onCallStop: (_) {}, + onStopTap: (_) {}, + onUnrejectStop: (_) async {}, ), ), ), @@ -78,100 +101,130 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('every call button shares one column', (tester) async { + testWidgets('every node sits on one vertical axis', (tester) async { await pumpRoute(tester); - final calls = tester - .widgetList(find.byIcon(Icons.call_rounded)) - .toList(); - expect(calls.length, 2, reason: 'two of the four stops carry a phone'); + final nodes = find.byType(PickupTimelineGroup); + expect(nodes.evaluate().length, greaterThan(1), reason: 'a mixed route'); - final rects = find - .byIcon(Icons.call_rounded) - .evaluate() - .map((e) => (e.renderObject! as RenderBox).localToGlobal(Offset.zero).dx) - .toList(); - - expect(rects.first, closeTo(rects.last, 0.01), - reason: 'the call buttons are at x=$rects — they must share a column ' - 'even though one stop is pending and the other rejected'); + // The spine is a fixed-width rail with the node centred in it, so this + // holds by construction — which is exactly why it is worth pinning. The + // moment a state draws a wider node or a row grows a leading control, the + // rail bends and the run stops being scannable. + final centres = [ + for (var i = 0; i < nodes.evaluate().length; i++) + tester.getRect(nodes.at(i)).left, + ]; + for (final c in centres) { + expect(c, closeTo(centres.first, 0.5)); + } }); - testWidgets('the call column does not move when the tick box is absent', - (tester) async { + testWidgets('the rail does not move when a row changes state', ( + tester, + ) async { await pumpRoute(tester); - // Stop A is pending and has a phone, so it shows BOTH controls. - // Stop C is rejected and has a phone, so it shows the call button ALONE. - // Before the fix, C's call button sat one tap-target further right. - final callXs = find - .byIcon(Icons.call_rounded) - .evaluate() - .map((e) => (e.renderObject! as RenderBox).localToGlobal(Offset.zero).dx) - .toSet(); + // Two of the four are listed now. The skipped one has always been + // Activity's row; the **rejected** one joined it — a declined stop leaves + // Home the moment it is declined rather than sitting struck through with an + // UNDO beside it. What is left on the route is the undecided work. + final names = find.text('Sri Balaji Stores'); + expect(names.evaluate().length, 2); - expect(callXs.length, 1, - reason: 'a missing tick box must not pull the call button across'); + final lefts = [ + for (var i = 0; i < 2; i++) tester.getRect(names.at(i)).left, + ]; + for (final l in lefts) { + expect( + l, + closeTo(lefts.first, 0.5), + reason: 'a rejected row must not indent differently from a pending one', + ); + } }); - testWidgets('the card and the rail each get their side gap', (tester) async { - await pumpRoute(tester); + testWidgets('every order in a place shares one axis', (tester) async { + // ── What this used to say, and why it changed ── + // + // It pinned the *place* node and the *order* nodes to one axis, because a + // spine ran from the header down through the steps and a stepped rail would + // have bent it. + // + // Two things removed that line. The place and its orders are grouped by a + // card now rather than by a thread, so the header draws no connector below + // it; and the orders sit in an inset panel *inside* that card, which is what + // makes them read as its contents. A panel that ignored its own container's + // inset to keep a nonexistent line straight would just look broken. + // + // What still matters — and what actually makes a run scannable — is that + // every step in a place shares one left edge, whatever state it is in. That + // is what is pinned here, plus the panel's own inset, so the nesting cannot + // drift a point at a time. + ServiceProfile.setActive(ServiceProfile.meals); + await pumpRoute(tester, kitchen: 'Vidhya Kitchen'); - // The rail node is the numbered circle; the card is the fill beside it. - final railLeft = tester.getRect(find.text('1')).left; - expect(railLeft, greaterThan(12), - reason: 'the rail should not run into the page gutter'); + final group = tester.getRect(find.byType(PickupTimelineGroup).first); + final steps = find.byType(PickupTimelineStep); + expect(steps.evaluate(), isNotEmpty, reason: 'the group is open'); - // Nothing is clipped or overflowing at the other edge either. - expect(tester.takeException(), isNull); + final lefts = [ + for (var i = 0; i < steps.evaluate().length; i++) + tester.getRect(steps.at(i)).left, + ]; + for (final l in lefts) { + expect(l, closeTo(lefts.first, 0.5), reason: 'one axis for every order'); + } + + expect( + lefts.first - group.left, + greaterThan(0), + reason: 'the orders sit inside the card, not flush against its edge', + ); + expect( + lefts.first - group.left, + // ≤ 22: the panel inset plus the well's own padding. The well returned + // when the route moved onto a white MilerPanel — open orders were + // white-on-white — so the deliberate indent is two small steps now, and + // this bound is what keeps it from becoming three. + lessThanOrEqualTo(22), + reason: + 'measured ${lefts.first - group.left} — a nested well, not a second ' + 'indent level growing a point at a time', + ); }); - testWidgets('the two controls are the same size', (tester) async { - await pumpRoute(tester); + testWidgets('the phone is not on an order row', (tester) async { + ServiceProfile.setActive(ServiceProfile.meals); + await pumpRoute(tester, kitchen: 'Vidhya Kitchen'); - final call = tester.getSize(find.byIcon(Icons.call_rounded).first); - expect(call.width, call.height, reason: 'the call button is square'); + // ── Re-scoped, and the contract is unchanged ── + // + // This asserted no call control anywhere on the route. That was right while + // there was nowhere sensible to put one — but ringing a kitchen is an action + // on the *place*, and it now lives on the pickup card beside Navigate, where + // the rider can reach it without opening a list of five customers he is not + // looking for. + // + // What the test was really protecting is the thing it is still protecting: a + // **step** carries who and which bag, and nothing else. Five call buttons + // dialling one counter is the meta row coming back one control at a time. + for ( + var i = 0; + i < find.byType(PickupTimelineStep).evaluate().length; + i++ + ) { + expect( + find.descendant( + of: find.byType(PickupTimelineStep).at(i), + matching: find.byIcon(LucideIcons.phone), + ), + findsNothing, + reason: 'step $i grew a call control', + ); + } - // Both controls render inside equal-width reserved slots. - expect(tester.takeException(), isNull); - }); - - testWidgets('every duration sits on one right-hand column', (tester) async { - await pumpRoute(tester); - - // "~5 min" is materially narrower than "~10 min". Left-aligned, that put the - // duration in a different place on every card and dragged the call button - // and tick box along with it. Each now sits in a fixed-width slot, - // right-aligned, so all four share a trailing edge. - final rights = find - .textContaining('~') - .evaluate() - .map((e) { - final box = e.renderObject! as RenderBox; - return box.localToGlobal(Offset.zero).dx + box.size.width; - }) - .toSet(); - - expect(rights.length, 1, - reason: 'durations end at $rights — they must share one column'); - }); - - testWidgets('the three trailing elements keep a constant spacing', - (tester) async { - await pumpRoute(tester); - - double leftOf(Finder f) => - (f.evaluate().first.renderObject! as RenderBox) - .localToGlobal(Offset.zero) - .dx; - - // Duration, then call, then tick — in that order, left to right, and the - // gaps between them are identical on every card because all three are - // fixed-width slots rather than content-sized children. - final duration = leftOf(find.textContaining('~')); - final call = leftOf(find.byIcon(Icons.call_rounded)); - expect(duration, lessThan(call), - reason: 'the duration reads before the controls it belongs to'); - expect(tester.takeException(), isNull); + // And exactly one on the place that owns it. + expect(find.byIcon(LucideIcons.phone), findsOneWidget); }); } diff --git a/test/stop_verify_chrome_test.dart b/test/stop_verify_chrome_test.dart index 20088d6..b6dc5b8 100644 --- a/test/stop_verify_chrome_test.dart +++ b/test/stop_verify_chrome_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -83,16 +84,29 @@ void main() { expect(fills, isNot(contains(ColorConstants.cardSurface))); }); - testWidgets('section names are quiet labels, not headings', (tester) async { + testWidgets('a section names itself, and stays under the stop', ( + tester, + ) async { await pump(tester, stopOf(type: 'delivery', deliveryQty: 1)); - // Small, letterspaced and grey — the group is named without competing with - // the controls inside it. - final label = tester.widget(find.text('WHO RECEIVED IT?')); - expect(label.style!.color, ColorConstants.secondaryText); - expect(label.style!.letterSpacing, greaterThan(0.8)); + // ── This assertion changed with the design, deliberately ── + // + // It used to require a grey letterspaced eyebrow, from when the sections + // were hairline-ruled groups on a white page: with no boundary of their + // own, a loud title would have been the only thing separating them, and + // five loud titles is a page of shouting. + // + // They are panels now. The boundary does the separating, so the title is + // free to be a title — the line the rider reads to know what this step + // asks of him — set in the same ink as the rest of the panel's content + // rather than in the grey reserved for metadata. + final label = tester.widget(find.text('Who received it?')); + expect(label.style!.color, ColorConstants.slateText); + expect(label.style!.fontWeight, FontWeight.w700); - // Quieter than the stop it belongs to, which is itself only context. + // What has not changed, and must not: it is still quieter than the stop it + // belongs to. The customer's name is the subject of the screen; a step + // inside it is not allowed to outrank it. final name = tester.widget(find.text('Priya Sharma')); expect(label.style!.fontSize, lessThan(name.style!.fontSize!)); }); @@ -135,7 +149,7 @@ void main() { // No accent button at all — a saturated pill at 45% still reads as a // button, so the rider taps it and nothing happens. expect(find.byType(MilerButton), findsNothing); - expect(find.byIcon(Icons.lock_outline_rounded), findsOneWidget); + expect(find.byIcon(LucideIcons.lock), findsOneWidget); // And the label stays readable: grey type on a neutral fill, not white // dropped to 45% over a wash of itself. diff --git a/test/stop_verify_layout_test.dart b/test/stop_verify_layout_test.dart index 58ee5f1..1ea1e7a 100644 --- a/test/stop_verify_layout_test.dart +++ b/test/stop_verify_layout_test.dart @@ -31,8 +31,9 @@ void main() { designSize: const Size(390, 844), builder: (_, _) => MaterialApp( builder: (context, child) => MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), child: child!, ), home: StopVerificationPage(pickup: stop), @@ -42,19 +43,22 @@ void main() { await tester.pumpAndSettle(); } - Map stopOf(String type, {int delivery = 0, int pickup = 0}) => - { - 'orderid': 'T-1', - 'type': type, - 'pickupcustomer': 'Venkateswara Traders & General Merchants', - 'pickupaddress': - '12/3B, Second Cross Street, Ram Nagar, Near Bus Stand, ' - 'Coimbatore, Tamil Nadu 641009', - if (delivery > 0) 'deliveryqty': '$delivery', - if (pickup > 0) 'pickupqty': '$pickup', - 'Quantity': '${pickup > 0 ? pickup : delivery}', - 'collectionamt': '2450', - }; + Map stopOf( + String type, { + int delivery = 0, + int pickup = 0, + }) => { + 'orderid': 'T-1', + 'type': type, + 'pickupcustomer': 'Venkateswara Traders & General Merchants', + 'pickupaddress': + '12/3B, Second Cross Street, Ram Nagar, Near Bus Stand, ' + 'Coimbatore, Tamil Nadu 641009', + if (delivery > 0) 'deliveryqty': '$delivery', + if (pickup > 0) 'pickupqty': '$pickup', + 'Quantity': '${pickup > 0 ? pickup : delivery}', + 'collectionamt': '2450', + }; for (final scale in const [1.0, 1.3, 1.5, 2.0]) { testWidgets('the collect leg lays out at ${scale}x', (tester) async { @@ -68,35 +72,37 @@ void main() { }); } - testWidgets('the whole collect leg is reachable by scrolling', - (tester) async { + testWidgets('the whole collect leg is reachable by scrolling', ( + tester, + ) async { await pump(tester, stopOf('pickup', pickup: 2)); // The last section of the longest leg. If the scroll view were sized wrong // — which is the failure mode of a Column inside a Column inside an // Expanded — this would never come into view. await tester.dragUntilVisible( - find.text('PICKUP CODE'), + find.text('Pickup code'), find.byType(SingleChildScrollView), const Offset(0, -200), ); - expect(find.text('PICKUP CODE'), findsOneWidget); + expect(find.text('Pickup code'), findsOneWidget); expect(tester.takeException(), isNull); }); - testWidgets('the action bar never scrolls away with the form', - (tester) async { + testWidgets('the action bar never scrolls away with the form', ( + tester, + ) async { await pump(tester, stopOf('pickup', pickup: 2)); final barAtRest = tester.getRect(find.text('Take a photo of the parcels')); - await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -400)); + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -400), + ); await tester.pumpAndSettle(); // The blocking hint lives in the pinned bar, so it is still exactly where // it was: what the rider must do next cannot scroll off a doorstep screen. - expect( - tester.getRect(find.text('Take a photo of the parcels')), - barAtRest, - ); + expect(tester.getRect(find.text('Take a photo of the parcels')), barAtRest); }); } diff --git a/test/stop_verify_review_test.dart b/test/stop_verify_review_test.dart index 32d4ccd..f0b733a 100644 --- a/test/stop_verify_review_test.dart +++ b/test/stop_verify_review_test.dart @@ -5,8 +5,36 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; +/// ── The line this page's own review still belongs to ── +/// +/// [StopVerificationPage] ends with a review only where nothing downstream +/// subsumes it. On **logistics** something does: the shipment desk opens +/// straight after, and its Review & Pay screen covers the parcel, the +/// addresses, the fare and the payment on one page — so a review here would be +/// the same facts twice, two screens apart. +/// +/// The review is therefore kept for a line that verifies without pricing, and +/// this is that line. Built here rather than shipped as a profile because no +/// signed client needs it yet; the day one does, this is the shape it takes. +/// See `ServiceProfile.capturesShipmentAddresses`. +const ServiceProfile _verifyOnlyLine = ServiceProfile( + line: ServiceLine.parcel, + label: 'Doormile', + jobNoun: 'booking', + jobNounPlural: 'bookings', + completionVerb: 'Picked up', + collectsCash: true, + needsVerification: true, + needsProofPhoto: true, + acceptsPerStop: true, + sourceIsKitchen: false, + handoffAt: HandoffPoint.accepted, + capturesShipmentAddresses: false, +); + /// The review step used to be untestable — "it sits behind a camera gate no /// widget test can pass" — so the only thing held about it was the pure packing /// rule in [packReviewFacts]. That left the screen itself unwatched, and it @@ -31,6 +59,9 @@ class _FakeCamera extends ImagePickerPlatform { } void main() { + setUp(() => ServiceProfile.setActive(_verifyOnlyLine)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + setUpAll(() async { final dir = await Directory.systemTemp.createTemp('miler_review'); final png = File('${dir.path}/proof.png') diff --git a/test/stop_verify_test.dart b/test/stop_verify_test.dart index 5ff7f71..8800cbd 100644 --- a/test/stop_verify_test.dart +++ b/test/stop_verify_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; @@ -33,17 +34,16 @@ void main() { int deliveryQty = 0, int pickupQty = 0, double cash = 0, - }) => - { - 'orderid': 'T-1', - 'type': type, - 'pickupcustomer': 'Priya Sharma', - 'pickupaddress': '45, Lake View Apartments, Coimbatore', - if (deliveryQty > 0) 'deliveryqty': '$deliveryQty', - if (pickupQty > 0) 'pickupqty': '$pickupQty', - 'Quantity': '${pickupQty > 0 ? pickupQty : deliveryQty}', - if (cash > 0) 'collectionamt': '$cash', - }; + }) => { + 'orderid': 'T-1', + 'type': type, + 'pickupcustomer': 'Priya Sharma', + 'pickupaddress': '45, Lake View Apartments, Coimbatore', + if (deliveryQty > 0) 'deliveryqty': '$deliveryQty', + if (pickupQty > 0) 'pickupqty': '$pickupQty', + 'Quantity': '${pickupQty > 0 ? pickupQty : deliveryQty}', + if (cash > 0) 'collectionamt': '$cash', + }; testWidgets('a pickup stop runs one collect leg', (tester) async { await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 3)); @@ -54,32 +54,36 @@ void main() { expect(find.text('Hand over'), findsNothing); // The collect leg's own controls. - expect(find.text('PARCELS COLLECTED'), findsOneWidget); + expect(find.text('Parcels collected'), findsOneWidget); expect(find.text('of 3 booked'), findsOneWidget); - expect(find.text('PICKUP CODE'), findsOneWidget); + expect(find.text('Pickup code'), findsOneWidget); }); - testWidgets('a delivery stop runs one hand-over leg with a parcel checklist', - (tester) async { - await pumpStop(tester, stopOf(type: 'delivery', deliveryQty: 2)); + testWidgets( + 'a delivery stop runs one hand-over leg with a parcel checklist', + (tester) async { + await pumpStop(tester, stopOf(type: 'delivery', deliveryQty: 2)); - expect(find.text('Verify delivery'), findsOneWidget); - expect(find.text('Hand over'), findsOneWidget); - expect(find.text('Collect'), findsNothing); + expect(find.text('Verify delivery'), findsOneWidget); + expect(find.text('Hand over'), findsOneWidget); + expect(find.text('Collect'), findsNothing); - // One tile per parcel — this is what stops a 2-parcel drop closing on 1. - expect(find.text('Parcel 1 of 2'), findsOneWidget); - expect(find.text('Parcel 2 of 2'), findsOneWidget); - expect(find.text('WHO RECEIVED IT?'), findsOneWidget); - expect(find.text('DELIVERY OTP'), findsOneWidget); + // One tile per parcel — this is what stops a 2-parcel drop closing on 1. + expect(find.text('Parcel 1 of 2'), findsOneWidget); + expect(find.text('Parcel 2 of 2'), findsOneWidget); + expect(find.text('Who received it?'), findsOneWidget); + expect(find.text('Delivery OTP'), findsOneWidget); - // Nothing about weight or condition on a delivery. - expect(find.text('Condition'), findsNothing); - }); + // Nothing about weight or condition on a delivery. + expect(find.text('Condition'), findsNothing); + }, + ); testWidgets('a combined stop runs hand over, then collect', (tester) async { await pumpStop( - tester, stopOf(type: 'combined', deliveryQty: 1, pickupQty: 2)); + tester, + stopOf(type: 'combined', deliveryQty: 1, pickupQty: 2), + ); expect(find.text('Verify stop'), findsOneWidget); // Three steps, delivery first — the rider walks up holding the outbound @@ -93,12 +97,13 @@ void main() { expect(find.text('Collect 2'), findsOneWidget); // Step 1 is the delivery leg. - expect(find.text('HAND OVER THE PARCELS'), findsOneWidget); - expect(find.text('PARCELS COLLECTED'), findsNothing); + expect(find.text('Hand over the parcels'), findsOneWidget); + expect(find.text('Parcels collected'), findsNothing); }); - testWidgets('the delivery leg will not advance without its proof', - (tester) async { + testWidgets('the delivery leg will not advance without its proof', ( + tester, + ) async { await pumpStop(tester, stopOf(type: 'delivery', deliveryQty: 1)); // Blocked from the start, and the bar says what is missing next. @@ -113,11 +118,12 @@ void main() { expect(find.text('Take a proof photo'), findsOneWidget); // Still on step 1 — the camera is the last gate and cannot be faked here. - expect(find.text('HAND OVER THE PARCELS'), findsOneWidget); + expect(find.text('Hand over the parcels'), findsOneWidget); }); - testWidgets('handing over to a third party demands their name', - (tester) async { + testWidgets('handing over to a third party demands their name', ( + tester, + ) async { await pumpStop(tester, stopOf(type: 'delivery', deliveryQty: 1)); await tester.tap(find.text('Parcel 1 of 1')); @@ -128,8 +134,10 @@ void main() { await tester.pumpAndSettle(); expect(find.text("Person's name"), findsOneWidget); - expect(find.text('Enter the name of the person who took it'), - findsOneWidget); + expect( + find.text('Enter the name of the person who took it'), + findsOneWidget, + ); }); testWidgets('a short count forces a written reason', (tester) async { @@ -137,22 +145,22 @@ void main() { // Pre-set to the booked count, so the common case needs no explanation. expect(find.text('3'), findsOneWidget); - expect(find.text('WHAT HAPPENED?'), findsNothing); + expect(find.text('What happened?'), findsNothing); - await tester.tap(find.byIcon(Icons.remove_rounded)); + await tester.tap(find.byIcon(LucideIcons.minus)); await tester.pumpAndSettle(); expect(find.text('1 fewer than the parcels booked'), findsOneWidget); - expect(find.text('WHAT HAPPENED?'), findsOneWidget); + expect(find.text('What happened?'), findsOneWidget); }); testWidgets('damaged goods force a written reason too', (tester) async { await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 1)); - expect(find.text('WHAT HAPPENED?'), findsNothing); + expect(find.text('What happened?'), findsNothing); await tester.tap(find.text('Damaged')); await tester.pumpAndSettle(); - expect(find.text('WHAT HAPPENED?'), findsOneWidget); + expect(find.text('What happened?'), findsOneWidget); }); testWidgets('COD is surfaced on the stop summary', (tester) async { diff --git a/test/support/golden_fonts.dart b/test/support/golden_fonts.dart new file mode 100644 index 0000000..d7cf001 --- /dev/null +++ b/test/support/golden_fonts.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/helpers/constants/Font_constant.dart'; + +/// Fonts a reference shot needs, loaded once per suite. +/// +/// ── Why every shot harness must call this ── +/// +/// A golden rendered without a font is a picture of the fallback, and the +/// fallback for an icon font is an empty box. That is not a loud failure — the +/// test passes, the image is written, and a whole page of missing glyphs +/// becomes the reference every future run is compared against. +/// +/// It has happened twice. Poppins ships as separate weight files and had to be +/// registered under one family before `w800` headings stopped rendering as +/// smeared SemiBold; then Lucide arrived as a package-declared font the test +/// binding does not resolve at all, and every icon on Activity came out as a +/// hollow square. One loader, so the next family added to the app is added +/// here rather than in five harnesses that will drift apart. +Future loadGoldenFonts() async { + final poppins = FontLoader(FontConstants.fontFamily); + for (final e in const <(String, FontWeight)>[ + ('Light', FontWeight.w300), + ('Regular', FontWeight.w400), + ('Medium', FontWeight.w500), + ('SemiBold', FontWeight.w600), + ('Bold', FontWeight.w700), + ('ExtraBold', FontWeight.w800), + ('Black', FontWeight.w900), + ]) { + final f = File('assets/fonts/Poppins/Poppins-${e.$1}.ttf'); + if (f.existsSync()) { + poppins.addFont(f.readAsBytes().then((b) => ByteData.view(b.buffer))); + } + } + await poppins.load(); + + await _loadIfPresent('MaterialIcons', const [ + '/opt/homebrew/share/flutter/bin/cache/artifacts/material_fonts/' + 'MaterialIcons-Regular.otf', + ]); + + // ── The family name is not `Lucide` ── + // + // Its `IconData` carries `fontPackage: 'lucide_icons_flutter'`, and Flutter + // resolves a packaged font under `packages//`. Registering + // it as plain `Lucide` loads a font nothing ever asks for, which looks + // exactly like not loading it at all: every glyph renders as a hollow box + // and the golden is written anyway. + // + // Both names are registered — the prefixed one is what the widgets resolve, + // and the bare one costs nothing and covers any direct use. + final lucide = _lucideCandidates(); + await _loadIfPresent('packages/lucide_icons_flutter/Lucide', lucide); + await _loadIfPresent('Lucide', lucide, quiet: true); +} + +Future _loadIfPresent( + String family, + List paths, { + bool quiet = false, +}) async { + for (final p in paths) { + final f = File(p); + if (!f.existsSync()) continue; + final loader = FontLoader(family) + ..addFont(f.readAsBytes().then((b) => ByteData.view(b.buffer))); + await loader.load(); + return; + } + // Absent is reported, never silent: the alternative is a reference image + // full of empty boxes that nothing ever flags. + if (quiet) return; + // ignore: avoid_print + print('[GOLDEN] $family not found — its glyphs will render as boxes'); +} + +List _lucideCandidates() { + final home = Platform.environment['HOME'] ?? ''; + final root = Directory('$home/.pub-cache/hosted/pub.dev'); + if (!root.existsSync()) return const []; + return [ + for (final d in root.listSync().whereType()) + if (d.path + .split(Platform.pathSeparator) + .last + .startsWith('lucide_icons_flutter-')) + '${d.path}/assets/lucide.ttf', + ]; +} diff --git a/test/surface_system_test.dart b/test/surface_system_test.dart new file mode 100644 index 0000000..1fc0fbc --- /dev/null +++ b/test/surface_system_test.dart @@ -0,0 +1,128 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE LAYERS HAVE TO BE VISIBLE TO BE LAYERS +/// +/// The app looked flat for a measurable reason, not an aesthetic one: white on +/// the page ground was a **1.124 : 1** step. A tonal difference does not begin +/// to read as a *different surface* until roughly 1.25, and on the budget LCD +/// this app ships to it needs the top of that band. So every screen that +/// wanted its content to separate grew a border or a shadow to do the ground's +/// job — which is how an app ends up looking like a stack of rounded +/// rectangles while its ground does nothing. +/// +/// There were also five near-identical light tokens, three of them within +/// 1.03 of the canvas: indistinguishable, and therefore five names for two +/// decisions. +/// +/// These pin the arithmetic. A contrast rule that lives only in a comment is a +/// rule that gets undone by the next person who nudges a hex. +/// ───────────────────────────────────────────────────────────────────────── +double _luminance(Color c) { + double channel(double v) => + v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble(); + return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b); +} + +double contrast(Color a, Color b) { + final la = _luminance(a); + final lb = _luminance(b); + final hi = math.max(la, lb); + final lo = math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +void main() { + group('the ladder has real rungs', () { + test('a working surface separates from the canvas without a border', () { + final step = contrast(MilerSurface.working, MilerSurface.canvas); + expect( + step, + greaterThanOrEqualTo(1.25), + reason: + 'white on the canvas is $step : 1 — below ~1.25 a surface does ' + 'not read as a surface, and screens start drawing their own ' + 'outlines to compensate', + ); + // And not so far that the page turns into a dark frame around content. + expect(step, lessThan(1.6)); + }); + + test('the canvas is never white', () { + expect(MilerSurface.canvas, isNot(MilerSurface.working)); + expect(MilerSurface.canvas, isNot(const Color(0xFFFFFFFF))); + }); + + test('raised and floating carry lift, working does not', () { + // What separates an action surface from a reading surface is that it + // appears to sit above the page — the fill is the same on purpose. + expect(MilerSurface.raisedShadow, isNotEmpty); + expect(MilerSurface.floatingShadow, isNotEmpty); + expect(MilerSurface.raised, MilerSurface.working); + }); + + test('the scrim carries the modal separation', () { + // A sheet that needs a heavy shadow to be legible is a sheet whose scrim + // is too weak. + expect(MilerSurface.scrim.a, greaterThan(0.3)); + }); + }); + + group('text stays readable on every layer', () { + // AA body is 4.5:1. Every operational colour is asserted on the canvas — + // the darkest ground text sits on — so a screen cannot fail by moving + // content from a card onto the page. + final grounds = { + 'canvas': MilerSurface.canvas, + 'working': MilerSurface.working, + }; + final inks = { + 'slateText': ColorConstants.slateText, + 'secondaryText': ColorConstants.secondaryText, + 'onSurfaceVariant': ColorConstants.onSurfaceVariant, + 'primary': ColorConstants.primary, + 'acceptGreen': ColorConstants.acceptGreen, + 'warning': ColorConstants.warning, + 'errorRed': ColorConstants.errorRed, + }; + + for (final g in grounds.entries) { + for (final ink in inks.entries) { + test('${ink.key} on ${g.key} clears AA', () { + final r = contrast(ink.value, g.value); + expect( + r, + greaterThanOrEqualTo(4.5), + reason: '${ink.key} on ${g.key} is ${r.toStringAsFixed(2)} : 1', + ); + }); + } + } + }); + + group('the near-duplicate tokens are gone or justified', () { + test('the inset tone is distinguishable from the surface it sits on', () { + // The only nesting the system allows. If it cannot be seen against the + // working surface it is furniture, not a layer. + final step = contrast(MilerSurface.inset, MilerSurface.working); + expect(step, greaterThan(1.05)); + }); + + test('brand red is identity, error red is not the same colour', () { + // Red must not mean two things. Navigation/primary and failure have to + // be tellable apart at a glance, outdoors. + expect(ColorConstants.primary, isNot(ColorConstants.errorRed)); + expect( + contrast(ColorConstants.primary, ColorConstants.errorRed), + greaterThan(1.15), + reason: 'brand red and error red must not read as the same ink', + ); + }); + }); +} diff --git a/test/tenant_mode_test.dart b/test/tenant_mode_test.dart new file mode 100644 index 0000000..5a38ab6 --- /dev/null +++ b/test/tenant_mode_test.dart @@ -0,0 +1,288 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE LOGIN, ONE APP, THE TENANT DECIDES +/// +/// ``` +/// phone ─▶ MPIN ─▶ verify-pin ─▶ tenantid / tenantname ─▶ ServiceProfile +/// ╱ ╲ +/// Milk Man Logistics +/// ``` +/// +/// ── The direction this must fail in ── +/// +/// Every assertion below has a mirror. It is easy to prove a milk-man tenant +/// gets the milk run; the half that breaks silently is a **logistics** rider +/// losing his cash screen or gaining a kitchen heading, and nobody files a bug +/// saying "the milk-man build looks fine". So an unknown tenant, a blank one and +/// a missing one all land on logistics, which is the profile that asks for more +/// rather than less. +/// +/// ── And the build flag no longer wins ── +/// +/// It used to be read first, because `verify-pin` returned no tenant and the +/// flag was the only signal there was. Now the account answers, and "which work +/// am I doing today?" belongs to the hub that rostered the rider rather than to +/// whoever compiled the APK — one build serves every rider, so a flag that +/// outranked the account meant one wrong build put *everyone* on the wrong flow. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future resolveWith(Map prefs) async { + SharedPreferences.setMockInitialValues(prefs); + return resolveServiceProfile(); + } + + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the tenant name off the login', () { + test('a milk-man tenant gets the milk run', () async { + for (final name in [ + 'milkman', + 'milk man', + 'milk-man', + 'milkrun', + 'milk run', + ]) { + final p = await resolveWith({'tenantname': name}); + expect(p.line, ServiceLine.milkMan, reason: name); + } + }); + + test('the older food-client names still resolve', () async { + // Existing accounts carry these. Dropping them would move live riders to + // a different flow on an app update, which is not a rename anyone asked + // for. + for (final name in ['meals', 'dailygrubs']) { + final p = await resolveWith({'tenantname': name}); + expect(p.line, ServiceLine.milkMan, reason: name); + } + }); + + test('a logistics tenant gets logistics', () async { + for (final name in ['doormile', 'logistics', 'parcel']) { + final p = await resolveWith({'tenantname': name}); + expect(p.line, ServiceLine.parcel, reason: name); + } + }); + + test('case and surrounding space do not matter', () async { + // `auth_provider` lowercases and trims before it stores, and the resolver + // does it again — a console entry of " MilkMan " must not silently put a + // rider on the wrong flow. + final p = await resolveWith({'tenantname': 'milkman'}); + expect(p.line, ServiceLine.milkMan); + }); + }); + + group('the tenant id off the login', () { + test('tenant 13 is the milk run', () async { + // The live rider this was found with: Rajan A, userid 38, tenant 13. + final p = await resolveWith({'tenantid': 13}); + expect(p.line, ServiceLine.milkMan); + }); + }); + + group('the tenant claim on the session token', () { + // ── The bug this group exists for ── + // + // A real login returned `{success, token, user:{...}}` with **no tenant in + // the body at all** — and a token whose payload said `tenantid: 13`. The + // app was being told the answer and could not hear it, so every rider + // resolved to the fallback line and the milk run was unreachable on a real + // account. + + /// A token whose payload carries [claims]. Signature is decorative: nothing + /// client-side verifies it, and nothing may start to. + String tokenWith(Map claims) { + String seg(Map m) => + base64Url.encode(utf8.encode(json.encode(m))).replaceAll('=', ''); + return '${seg({'alg': 'HS256', 'typ': 'JWT'})}.${seg(claims)}.sig'; + } + + test('reads the tenant a real token claims', () { + expect( + ApiConfig.tenantIdFromToken( + tokenWith({'userid': 38, 'roleid': 5, 'tenantid': 13}), + ), + 13, + ); + }); + + test('an already-signed-in rider is resolved from his session', () async { + // No tenant in prefs — exactly what an older build left behind. He must + // not have to log out and back in to land on the right flow. + final p = await resolveWith({ + 'authtoken': tokenWith({'userid': 38, 'tenantid': 13}), + }); + expect(p.line, ServiceLine.milkMan); + }); + + test('an upgraded install with a stale tenant 0 self-heals', () async { + // The exact state a phone is in after upgrading from a build that wrote + // `tenantid: 0` into prefs because the login body had no tenant. Zero is + // not a tenant, so it must not be treated as one and must not stop the + // fall-through to the live session's claim — otherwise the rider has to + // log out and back in to reach the right flow, and nothing on screen + // would tell him to. + final p = await resolveWith({ + 'tenantid': 0, + 'tenantname': '', + 'authtoken': tokenWith({'userid': 38, 'tenantid': 13}), + }); + expect(p.line, ServiceLine.milkMan); + }); + + test('the persisted tenant still wins over the claim', () async { + final p = await resolveWith({ + 'tenantname': 'logistics', + 'authtoken': tokenWith({'tenantid': 13}), + }); + expect( + p.line, + ServiceLine.parcel, + reason: + 'the stored pair is the fresher answer; the claim is a backstop', + ); + }); + + test('a junk token decides nothing and throws nothing', () async { + for (final bad in ['', 'not-a-jwt', 'a.b', 'a.!!!!.c', 'a.e30.c']) { + expect(ApiConfig.tenantIdFromToken(bad), 0, reason: bad); + } + expect(ApiConfig.tenantIdFromToken(null), 0); + // And a session holding one falls through rather than failing to launch. + final p = await resolveWith({'authtoken': 'not-a-jwt'}); + expect(p.line, ServiceLine.parcel); + }); + + test('a token claiming tenant 0 decides nothing', () async { + expect(ApiConfig.tenantIdFromToken(tokenWith({'tenantid': 0})), 0); + final p = await resolveWith({ + 'authtoken': tokenWith({'tenantid': 0}), + }); + expect(p.line, ServiceLine.parcel); + }); + + test('a token claiming an unknown tenant falls back safely', () async { + final p = await resolveWith({ + 'authtoken': tokenWith({'tenantid': 4242}), + }); + expect(p.line, ServiceLine.parcel); + }); + }); + + group('the safe fallback', () { + test('an unknown tenant name is logistics', () async { + final p = await resolveWith({'tenantname': 'someclient'}); + expect(p.line, ServiceLine.parcel); + }); + + test('an unknown tenant id is logistics', () async { + final p = await resolveWith({'tenantid': 4242}); + expect(p.line, ServiceLine.parcel); + }); + + test('no tenant at all is logistics', () async { + final p = await resolveWith({}); + expect(p.line, ServiceLine.parcel); + }); + + test('a blank tenant name is logistics', () async { + final p = await resolveWith({'tenantname': ' '}); + expect(p.line, ServiceLine.parcel); + }); + + test('the fallback keeps the screens that ask for MORE', () async { + // The worst case this way is a milk-run rider seeing a payment prompt he + // can dismiss. The other way takes the cash screen away from a logistics + // rider standing at a door with a customer waiting to pay. + final p = await resolveWith({}); + expect(p.collectsCash, isTrue); + expect(p.capturesShipmentAddresses, isTrue); + expect(p.needsVerification, isTrue); + }); + }); + + group('the name is checked before the id', () { + test('a recognised name wins over an unrecognised id', () async { + final p = await resolveWith({'tenantname': 'milkman', 'tenantid': 4242}); + expect(p.line, ServiceLine.milkMan); + }); + + test('an unrecognised name falls through to the id', () async { + // The registry is keyed by name today, so this proves the fall-through + // path exists for the day ops would rather pin a number. + expect(TenantController.profileForName('nobody'), isNull); + expect(TenantController.profileForId(4242), isNull); + final p = await resolveWith({'tenantname': 'nobody', 'tenantid': 4242}); + expect(p.line, ServiceLine.parcel); + }); + }); + + group('what each mode can and cannot do', () { + test('the milk run has no shipment desk and takes no money', () { + const p = ServiceProfile.milkMan; + expect(p.collectsCash, isFalse); + expect(p.capturesShipmentAddresses, isFalse); + expect(p.pricesShipment, isFalse); + expect(p.initiatesShipment, isFalse); + expect(p.needsVerification, isFalse); + }); + + test('the milk run collects at a source and delivers it itself', () { + const p = ServiceProfile.milkMan; + expect(p.sourceIsKitchen, isTrue); + expect(p.bulkLoadProof, isTrue); + expect(p.handsOffAtCollection, isTrue); + expect(p.deliversToCustomer, isTrue); + expect(p.startsDeliveryAfterFullLoad, isTrue); + }); + + test('logistics runs the whole shipment desk', () { + const p = ServiceProfile.parcel; + expect(p.capturesShipmentAddresses, isTrue); + expect(p.pricesShipment, isTrue); + expect(p.initiatesShipment, isTrue); + expect(p.collectsCash, isTrue); + expect(p.needsVerification, isTrue); + }); + + test('logistics has no kitchen and no round of its own', () { + const p = ServiceProfile.parcel; + expect(p.sourceIsKitchen, isFalse); + expect(p.bulkLoadProof, isFalse); + expect(p.startsDeliveryAfterFullLoad, isFalse); + // Its shipment goes to the hub and somebody else delivers it, which is + // why this line ends at a warehouse and the milk run ends at a door. + expect(p.deliversToCustomer, isFalse); + expect(p.handsOffAtCollection, isFalse); + }); + + test('accepted work goes to a different place on each line', () { + // The single rule §12 of the brief turns on. + expect(ServiceProfile.parcel.handoffAt, HandoffPoint.accepted); + expect(ServiceProfile.milkMan.handoffAt, HandoffPoint.collected); + }); + }); + + group('the old names still compile to the same thing', () { + test('meals is milkMan', () { + expect(identical(ServiceProfile.meals, ServiceProfile.milkMan), isTrue); + expect(ServiceProfile.milkMan.isMeals, isTrue); + expect(ServiceProfile.milkMan.isMilkMan, isTrue); + }); + + test('parcel is logistics', () { + expect(ServiceProfile.parcel.isParcel, isTrue); + expect(ServiceProfile.parcel.isLogistics, isTrue); + }); + }); +} diff --git a/test/text_scale_sweep_test.dart b/test/text_scale_sweep_test.dart index 0b4a736..f16ce9d 100644 --- a/test/text_scale_sweep_test.dart +++ b/test/text_scale_sweep_test.dart @@ -98,7 +98,10 @@ void main() { ); expect(tester.takeException(), isNull); // Still readable, still not on top of anything. - expect(find.text('45, Lake View Apartments, Coimbatore'), findsOneWidget); + expect( + find.text('45, Lake View Apartments, Coimbatore'), + findsOneWidget, + ); }); } }); diff --git a/test/trip_brief_layout_test.dart b/test/trip_brief_layout_test.dart index 1332d47..1a43c2e 100644 --- a/test/trip_brief_layout_test.dart +++ b/test/trip_brief_layout_test.dart @@ -82,8 +82,6 @@ void main() { reason: 'collapsed brief overflowed at ${width}pt / ${scale}x', ); - await tester.tap(find.byType(TripBriefStrip)); - await tester.pumpAndSettle(); expect( tester.takeException(), isNull, diff --git a/test/trip_empty_state_test.dart b/test/trip_empty_state_test.dart index e1b98f5..c637f6e 100644 --- a/test/trip_empty_state_test.dart +++ b/test/trip_empty_state_test.dart @@ -34,9 +34,7 @@ void main() { ), ), ); - // Not `pumpAndSettle`: the halo breathes on a repeating controller, so - // there is deliberately no steady state to settle into. - await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); } /// Any decoration carrying a border, anywhere in the state. @@ -46,34 +44,45 @@ void main() { return d is BoxDecoration && d.border != null; }); - testWidgets('names the trip the tabs name, and draws no card', ( - tester, - ) async { + testWidgets('one line and the artwork, and no card', (tester) async { await pump(tester, offline: false); - expect(find.text('Trip 2 not assigned yet'), findsOneWidget); + // ── The line is fixed copy now, not the tab's name ── + // + // It read `${'$'}{tripLabel} not assigned yet` — "Trip 2 not assigned yet" — + // so each tab named itself. The user asked for one line, `Trip not + // assigned yet`, on every tab; the tab bar directly above already says + // which trip is selected, which is where that answer now lives alone. + expect(find.text('Trip not assigned yet'), findsOneWidget); expect(borderedBoxes(), findsNothing); }); - testWidgets('says the app is still waiting rather than offering nothing', ( + testWidgets('the artwork carries the wait — no paragraph, no chip', ( tester, ) async { await pump(tester, offline: false); - expect(find.text('Waiting for your hub'), findsOneWidget); - }); - testWidgets('off duty is a different sentence with the action that fixes it', ( - tester, - ) async { - var tapped = false; - await pump(tester, offline: true, onGoOnDuty: () => tapped = true); - - expect(find.text('You are off duty'), findsOneWidget); - // Nothing is coming until he goes on duty, so the wait is not claimed. + // The illustration says "waiting for work" in one look, so the two + // sentences and the `● Waiting for your hub` pill under it are gone. + // Anything that reintroduces prose here should fail this. + expect(find.byType(Image), findsOneWidget); expect(find.text('Waiting for your hub'), findsNothing); - - await tester.tap(find.text('Go on duty')); - await tester.pump(); - expect(tapped, isTrue); + expect(find.textContaining('Your hub assigns trips'), findsNothing); }); + + testWidgets( + 'off duty is a different sentence with the action that fixes it', + (tester) async { + var tapped = false; + await pump(tester, offline: true, onGoOnDuty: () => tapped = true); + + expect(find.text('You are off duty'), findsOneWidget); + // Nothing is coming until he goes on duty, so the wait is not claimed. + expect(find.text('Waiting for your hub'), findsNothing); + + await tester.tap(find.text('Go on duty')); + await tester.pump(); + expect(tapped, isTrue); + }, + ); } diff --git a/test/trip_progress_test.dart b/test/trip_progress_test.dart index 023a5e7..9c0c888 100644 --- a/test/trip_progress_test.dart +++ b/test/trip_progress_test.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_progress_rail.dart'; +import 'package:miler/Models/stop_status.dart'; /// The whole point of replacing the node rail is that route length no longer /// changes the widget's shape. A hub can assign 6 stops or 40; the summary at @@ -35,8 +38,12 @@ void main() { await tester.pumpAndSettle(); } - List route(int done, int skipped, int pending, - {bool current = true}) { + List route( + int done, + int skipped, + int pending, { + bool current = true, + }) { return [ for (var i = 0; i < done; i++) StopProgress.done, if (current) StopProgress.current, @@ -45,16 +52,18 @@ void main() { ]; } - testWidgets('height does not grow with the number of stops', - (tester) async { + testWidgets('height does not grow with the number of stops', (tester) async { await pump(tester, route(2, 1, 3)); final short = tester.getSize(find.byType(TripProgressRail)).height; await pump(tester, route(8, 3, 28)); // 40 stops final long = tester.getSize(find.byType(TripProgressRail)).height; - expect(long, short, - reason: 'the summary must not stretch or squeeze with route length'); + expect( + long, + short, + reason: 'the summary must not stretch or squeeze with route length', + ); }); testWidgets('never overflows, even on a long route', (tester) async { @@ -74,8 +83,7 @@ void main() { ); }); - testWidgets('drops the skip clause when nothing was skipped', - (tester) async { + testWidgets('drops the skip clause when nothing was skipped', (tester) async { await pump(tester, route(3, 0, 5)); expect(find.text('On stop 4 of 9 · 2h 10m left, then hub'), findsOneWidget); }); @@ -87,20 +95,64 @@ void main() { await pump(tester, route(4, 2, 11)); final boxes = tester - .widgetList(find.descendant( - of: find.byType(TripProgressRail), - matching: find.byType(Container), - )) + .widgetList( + find.descendant( + of: find.byType(TripProgressRail), + matching: find.byType(Container), + ), + ) .map((c) => c.decoration) .whereType(); for (final d in boxes) { + // ── A ring on a node is a mark; a border on a rectangle is a box ── + // + // The rule this protects is that the rail sits bare on the page — no + // card, no panel, no outline around the run. Circular nodes are exempt + // and always were in spirit: the route timeline draws hollow rings for + // exactly the same reason, and the rail's two framing nodes (where the + // rider is, where his day ends) are hollow so a place he does not work + // never competes with one he does. + if (d.shape == BoxShape.circle) continue; expect(d.border, isNull, reason: 'no bordered container inside the rail'); - expect(d.boxShadow, anyOf(isNull, isEmpty), - reason: 'no elevation inside the rail'); + expect( + d.boxShadow, + anyOf(isNull, isEmpty), + reason: 'no elevation inside the rail', + ); } }); + // ── A run of one is a journey, not a badge ── + // + // A rider with a single accepted order met one red circled **1** at the top + // of Deliveries: a counter, or a notification badge, but not a route. The rail + // is framed by the two places the run actually goes between — where he is + // standing, and where the day ends — so even one stop reads as + // "here → this pickup → done". + testWidgets('one stop still reads as a journey', (tester) async { + await pump(tester, route(1, 0, 0)); + + expect( + find.byIcon(LucideIcons.locateFixed), + findsOneWidget, + reason: 'the run starts where the rider is', + ); + // Parcel is the default profile in these tests: its day ends at the depot. + expect(find.byIcon(LucideIcons.warehouse), findsOneWidget); + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('a milk run ends at home, not at a warehouse', (tester) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + await pump(tester, route(1, 0, 0)); + + expect(find.byIcon(LucideIcons.house), findsOneWidget); + expect(find.byIcon(LucideIcons.warehouse), findsNothing); + }); + testWidgets('a finished route says head back to the hub', (tester) async { await pump(tester, route(6, 0, 0, current: false)); expect(find.text('All stops done'), findsOneWidget); @@ -109,7 +161,9 @@ void main() { // The rail numbers the stops the same way the list below it does, and the // finished ones are the coloured ones. This is the whole point of the nodes. - testWidgets('draws a numbered node per stop, and one scooter', (tester) async { + testWidgets('draws a numbered node per stop, and one scooter', ( + tester, + ) async { await pump(tester, route(2, 0, 2)); // 5 stops, current is #3 // Every stop keeps its number, including the one he is on — the scooter @@ -117,14 +171,15 @@ void main() { for (final n in ['1', '2', '3', '4', '5']) { expect(find.text(n), findsOneWidget, reason: 'stop $n is on the rail'); } - expect(find.byIcon(Icons.two_wheeler_rounded), findsOneWidget); + expect(find.byIcon(LucideIcons.bike), findsOneWidget); }); // The point of the overlay: finishing a stop *moves* the marker, visibly, // instead of repainting two circles. - testWidgets('the scooter rides forward when a stop is completed', - (tester) async { - Finder scooter() => find.byIcon(Icons.two_wheeler_rounded); + testWidgets('the scooter rides forward when a stop is completed', ( + tester, + ) async { + Finder scooter() => find.byIcon(LucideIcons.bike); await pump(tester, route(2, 0, 2)); // on stop 3 of 5 final from = tester.getCenter(scooter()).dx; @@ -145,17 +200,21 @@ void main() { testWidgets('a finished route parks the scooter', (tester) async { await pump(tester, route(2, 0, 3, current: false)); // 5 stops, none active // Nothing is `current`, so the first pending stop holds the scooter. - expect(find.byIcon(Icons.two_wheeler_rounded), findsOneWidget); + expect(find.byIcon(LucideIcons.bike), findsOneWidget); await pump(tester, route(5, 0, 0, current: false)); // all done - expect(find.byIcon(Icons.two_wheeler_rounded), findsNothing, - reason: 'nothing left to ride to'); + expect( + find.byIcon(LucideIcons.bike), + findsNothing, + reason: 'nothing left to ride to', + ); }); // Long routes scroll rather than shrink — the failure that killed the first // version of this rail was nodes being squeezed to fit a fixed width. - testWidgets('a long route scrolls instead of crushing its nodes', - (tester) async { + testWidgets('a long route scrolls instead of crushing its nodes', ( + tester, + ) async { await pump(tester, route(4, 0, 35)); // 40 stops expect(find.byType(SingleChildScrollView), findsWidgets); expect(tester.takeException(), isNull); @@ -166,4 +225,98 @@ void main() { expect(find.text('START'), findsNothing); expect(find.text('RETURN'), findsNothing); }); + + // ── The scrolling edge has to read as "more route", not as a clip ── + // + // A 21-stop round draws about seven nodes on a 390pt phone and sliced the + // eighth down its middle at the viewport edge. A half circle stopping in mid + // air reads as a rendering fault, and it is the one edge that has to invite + // the push that reveals the rest of the day. + testWidgets('a scrolling rail fades at its edges; a short one does not', ( + tester, + ) async { + await pump(tester, route(4, 0, 35)); // 40 stops — scrolls + expect( + find.descendant( + of: find.byType(TripProgressRail), + matching: find.byType(ShaderMask), + ), + findsOneWidget, + reason: 'the rail continues past the edge and must say so', + ); + + // Four. The rail reserves a framing node at each end and holds a 22pt + // floor under the gaps, so against a 390pt phone's 358pt of content width + // only six nodes — four stops — lay out without scrolling. Which is worth + // knowing on its own: the fade is not a long-route embellishment, it is + // what almost every real round gets. + await pump(tester, route(1, 0, 2)); // 4 stops — fits + expect( + find.descendant( + of: find.byType(TripProgressRail), + matching: find.byType(ShaderMask), + ), + findsNothing, + reason: + 'a route that fits ends where it ends — fading it would hint at ' + 'work that does not exist', + ); + }); + + // ── The rung → rail mapping, in both legs' vocabulary ── + // + // `stopProgressFor` is read on the Deliveries tab, where every stop is past + // `pickup-complete` by definition. It used to ask `isActive || arrived` — + // both pickup-leg rungs — so the customer's door the rider was standing at + // matched nothing and drew grey. + group('stopProgressFor', () { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + test('the door the rider is at is where the scooter goes', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + expect( + stopProgressFor(StopStatus.deliveryArrived), + StopProgress.current, + reason: + 'he is standing at it — this is the whole question the rail ' + 'answers', + ); + }); + + test('the pickup leg still resolves', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + expect(stopProgressFor(StopStatus.arrived), StopProgress.current); + expect(stopProgressFor(StopStatus.active), StopProgress.current); + }); + + test('a carried bag is not a delivered one', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + // `picked` and `outForDelivery` are both "aboard, not handed over". They + // are ahead of the rider, not behind him — see `picked_not_active_test`. + expect(stopProgressFor(StopStatus.picked), StopProgress.pending); + expect(stopProgressFor(StopStatus.outForDelivery), StopProgress.pending); + expect(stopProgressFor(StopStatus.delivered), StopProgress.done); + }); + + test('done is line-aware', () { + ServiceProfile.setActive(ServiceProfile.parcel); + expect( + stopProgressFor(StopStatus.picked), + StopProgress.done, + reason: 'collection IS the job on logistics', + ); + + ServiceProfile.setActive(ServiceProfile.milkMan); + expect( + stopProgressFor(StopStatus.picked), + StopProgress.pending, + reason: 'on a round the same rung is the middle of the morning', + ); + }); + + test('a skipped stop keeps its own state', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + expect(stopProgressFor(StopStatus.skipped), StopProgress.skipped); + }); + }); } diff --git a/test/trip_tabs_pinned_test.dart b/test/trip_tabs_pinned_test.dart index a48f062..f87c7ce 100644 --- a/test/trip_tabs_pinned_test.dart +++ b/test/trip_tabs_pinned_test.dart @@ -31,12 +31,18 @@ void main() { const belowKey = ValueKey('below'); final trips = [ - Trip.fromStops(id: 't1', stops: [ - {'orderid': 'A', 'type': 'pickup', 'pickupcustomer': 'Sri Balaji'}, - ]), - Trip.fromStops(id: 't2', stops: [ - {'orderid': 'B', 'type': 'delivery', 'pickupcustomer': 'Anand'}, - ]), + Trip.fromStops( + id: 't1', + stops: [ + {'orderid': 'A', 'type': 'pickup', 'pickupcustomer': 'Sri Balaji'}, + ], + ), + Trip.fromStops( + id: 't2', + stops: [ + {'orderid': 'B', 'type': 'delivery', 'pickupcustomer': 'Anand'}, + ], + ), ]; /// Pumps the real delegate inside a scroll view, at a given text scale, and @@ -58,8 +64,9 @@ void main() { designSize: const Size(390, 844), builder: (_, _) => MaterialApp( builder: (context, child) => MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), child: child!, ), home: Scaffold( @@ -110,18 +117,29 @@ void main() { testWidgets('at ${scale}x text scale', (tester) async { final extent = await pump(tester, textScale: scale); - expect(tester.takeException(), isNull, - reason: 'the tabs must not overflow the reserved extent'); + expect( + tester.takeException(), + isNull, + reason: 'the tabs must not overflow the reserved extent', + ); final tabs = tester.getSize(find.byType(TripTabs)); - expect(tabs.height, lessThanOrEqualTo(extent + 0.01), - reason: 'tabs are ${tabs.height} tall but only $extent was ' - 'reserved — the bar would be clipped'); + expect( + tabs.height, + lessThanOrEqualTo(extent + 0.01), + reason: + 'tabs are ${tabs.height} tall but only $extent was ' + 'reserved — the bar would be clipped', + ); // Over-reserving is the safe direction, but not by a wasteful amount. - expect(extent - tabs.height, lessThan(14), - reason: 'reserving $extent for ${tabs.height} of content is ' - 'more dead space than the safety margin intends'); + expect( + extent - tabs.height, + lessThan(14), + reason: + 'reserving $extent for ${tabs.height} of content is ' + 'more dead space than the safety margin intends', + ); // All three slots are still rendered and readable at this scale. for (final label in ['Trip 1', 'Trip 2', 'Trip 3']) { @@ -132,8 +150,9 @@ void main() { }); group('pinning', () { - testWidgets('the bar holds at the top while content scrolls under it', - (tester) async { + testWidgets('the bar holds at the top while content scrolls under it', ( + tester, + ) async { await pump(tester); final atRest = tester.getRect(find.byType(TripTabs)); // It starts below the 300pt block above it, not stuck to the top. @@ -143,10 +162,16 @@ void main() { await tester.pumpAndSettle(); final pinned = tester.getRect(find.byType(TripTabs)); - expect(pinned.top, lessThan(atRest.top), - reason: 'it should travel up with the page first'); - expect(pinned.top, lessThan(40), - reason: 'and then hold near the top of the viewport'); + expect( + pinned.top, + lessThan(atRest.top), + reason: 'it should travel up with the page first', + ); + expect( + pinned.top, + lessThan(40), + reason: 'and then hold near the top of the viewport', + ); // Scrolling further does not move it again. await tester.dragFrom(inViewport, const Offset(0, -600)); @@ -176,8 +201,11 @@ void main() { await tester.tap(find.text('Trip 1')); await tester.pumpAndSettle(); - expect(picked, 0, - reason: 'switching trips from deep in the list is the whole point'); + expect( + picked, + 0, + reason: 'switching trips from deep in the list is the whole point', + ); }); }); } diff --git a/test/trip_test.dart b/test/trip_test.dart index d21ed49..c850411 100644 --- a/test/trip_test.dart +++ b/test/trip_test.dart @@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; -import 'fixtures/demo_stops.dart'; Map stop({ required String id, @@ -13,17 +12,18 @@ Map stop({ double lat = 11.03, double lng = 76.96, DateTime? due, -}) => - { - 'orderid': id, - if (step != null) 'step': step, - 'type': type, - 'Quantity': '$qty', - if (cash != null) 'collectionamt': '$cash', - 'pickuplat': '$lat', - 'pickuplon': '$lng', - if (due != null) 'expected_pickup_time': due.toIso8601String(), - }; + String? address, +}) => { + 'orderid': id, + if (step != null) 'step': step, + 'type': type, + 'Quantity': '$qty', + if (cash != null) 'collectionamt': '$cash', + 'pickuplat': '$lat', + 'pickuplon': '$lng', + if (due != null) 'expected_pickup_time': due.toIso8601String(), + if (address != null) 'pickupaddress': address, +}; void main() { group('RouteMetricsHelper — distance units', () { @@ -56,14 +56,20 @@ void main() { group('RouteMetricsHelper — speed and ETA', () { test('falls back to the urban average when speed is unusable', () { - expect(RouteMetricsHelper.usableSpeedMps(null), - RouteMetricsHelper.kDefaultSpeedMps); + expect( + RouteMetricsHelper.usableSpeedMps(null), + RouteMetricsHelper.kDefaultSpeedMps, + ); // Stopped at a light must not stretch the ETA to an hour. - expect(RouteMetricsHelper.usableSpeedMps(0.4), - RouteMetricsHelper.kDefaultSpeedMps); + expect( + RouteMetricsHelper.usableSpeedMps(0.4), + RouteMetricsHelper.kDefaultSpeedMps, + ); // A GPS spike is a bad fix, not a fast rider. - expect(RouteMetricsHelper.usableSpeedMps(60), - RouteMetricsHelper.kDefaultSpeedMps); + expect( + RouteMetricsHelper.usableSpeedMps(60), + RouteMetricsHelper.kDefaultSpeedMps, + ); }); test('uses a real riding speed when it is plausible', () { @@ -79,31 +85,123 @@ void main() { }); test('formats travel time readably', () { - expect(RouteMetricsHelper.formatDuration(const Duration(minutes: 4)), - '4 min'); expect( - RouteMetricsHelper.formatDuration( - const Duration(hours: 1, minutes: 5)), - '1h 05m'); + RouteMetricsHelper.formatDuration(const Duration(minutes: 4)), + '4 min', + ); + expect( + RouteMetricsHelper.formatDuration(const Duration(hours: 1, minutes: 5)), + '1h 05m', + ); }); }); group('Trip — grouping into slots', () { - test('buckets stops into clock-aligned slot windows', () { + test('buckets stops by day part — morning, afternoon, evening', () { final trips = Trip.groupIntoTrips([ stop(id: 'a', due: DateTime(2026, 7, 27, 10, 15)), stop(id: 'b', due: DateTime(2026, 7, 27, 12, 40)), stop(id: 'c', due: DateTime(2026, 7, 27, 14, 5)), ]); - // 10:15 and 12:40 both fall in the 09:00–12:00 / 12:00–15:00 blocks. + // 12:40 and 14:05 are both the afternoon run. Under the old rolling + // three-hour windows they landed on different trips, which made "which + // trip is this on?" depend on the clock rather than on the shift. expect(trips.length, 2); - expect(trips.first.slotStart, DateTime(2026, 7, 27, 9)); - expect(trips.first.slotEnd, DateTime(2026, 7, 27, 12)); + expect(trips.first.dayPart, DayPart.morning); + expect(trips.first.tripNumber, 1); expect(trips.first.stopCount, 1); + expect(trips.last.dayPart, DayPart.afternoon); + expect(trips.last.tripNumber, 2); expect(trips.last.stopCount, 2); }); + test('an afternoon-only day is Trip 2, not Trip 1', () { + // The number is the day part. A rider whose work starts at two o'clock + // used to see it as Trip 1, so his Trip 2 and the hub's were different + // runs — the exact confusion this rule removes. + final trips = Trip.groupIntoTrips([ + stop(id: 'a', due: DateTime(2026, 7, 27, 14, 0)), + ]); + expect(trips.single.tripNumber, 2); + expect(trips.tripAt(0), isNull, reason: 'no morning work'); + expect(trips.tripAt(1), isNotNull); + expect(trips.tripAt(2), isNull, reason: 'no evening work'); + }); + + test('two runs in one part keep their order, at any hour', () { + // ── The flake this pins ── + // + // Day-part slotting placed the first trip in its part and sent the + // second to the first free slot *anywhere*, so two evening runs came out + // `[2, 0]`: trip one under Trip 3, trip two under Trip 1 — inverted, and + // only between 5pm and midnight. A layout that depends on the hour the + // screen was opened is worse than one that ignores day parts. + // + // All or nothing: every trip has its own part, or the run is laid out in + // the order it arrived. Checked at all three parts so no hour is special. + for (final hour in const [9, 14, 19]) { + final trips = Trip.groupIntoTrips([ + { + ...stop(id: 'first'), + 'tripid': 'A', + 'expected_pickup_time': DateTime( + 2026, + 8, + 21, + hour, + 0, + ).toIso8601String(), + }, + { + ...stop(id: 'second'), + 'tripid': 'B', + 'expected_pickup_time': DateTime( + 2026, + 8, + 21, + hour, + 30, + ).toIso8601String(), + }, + ]); + expect(trips.length, 2, reason: 'two hub trips at $hour:00'); + + final slots = trips.slots; + expect( + slots[0]?.stops.first['orderid'], + 'first', + reason: 'the earlier run leads, at $hour:00', + ); + expect( + slots[1]?.stops.first['orderid'], + 'second', + reason: 'and the later one follows, at $hour:00', + ); + } + }); + + test('the three parts split at noon and five', () { + expect(DayPart.of(DateTime(2026, 7, 27, 11, 59)), DayPart.morning); + expect(DayPart.of(DateTime(2026, 7, 27, 12, 0)), DayPart.afternoon); + expect(DayPart.of(DateTime(2026, 7, 27, 16, 59)), DayPart.afternoon); + expect(DayPart.of(DateTime(2026, 7, 27, 17, 0)), DayPart.evening); + expect(DayPart.of(DateTime(2026, 7, 27, 23, 30)), DayPart.evening); + }); + + test('a full day is three trips, never four', () { + final trips = Trip.groupIntoTrips([ + for (var h = 6; h < 22; h += 2) + stop(id: 'h$h', due: DateTime(2026, 7, 27, h, 30)), + ]); + expect(trips.length, 3); + expect( + trips.map((t) => t.tripNumber), + [1, 2, 3], + reason: 'the day has three parts and the tabs have three slots', + ); + }); + test('an explicit backend trip id wins over time bucketing', () { final trips = Trip.groupIntoTrips([ {...stop(id: 'a', due: DateTime(2026, 7, 27, 10, 0)), 'tripid': 'T1'}, @@ -129,12 +227,30 @@ void main() { expect(trips.first.stops.first['orderid'], 'early'); }); - test('formats the slot window as a clock range', () { - final trips = Trip.groupIntoTrips( - [stop(id: 'a', due: DateTime(2026, 7, 27, 11, 30))], - windowHours: 3, + test('names the part of the day rather than spelling out a range', () { + final trips = Trip.groupIntoTrips([ + stop(id: 'a', due: DateTime(2026, 7, 27, 11, 30)), + ]); + expect(trips.single.slotLabel, 'Morning'); + expect( + Trip.groupIntoTrips([ + stop(id: 'b', due: DateTime(2026, 7, 27, 19, 0)), + ]).single.slotLabel, + 'Evening', ); - expect(trips.single.slotLabel, '9:00 AM – 12:00 PM'); + }); + + test('a backend slot window is still shown as a clock range', () { + // A tenant that sends real slot times means them literally, and + // 10:00–13:00 is not "the morning". + final trips = Trip.groupIntoTrips([ + { + ...stop(id: 'a', due: DateTime(2026, 7, 27, 11, 30)), + 'slotstarttime': DateTime(2026, 7, 27, 10).toIso8601String(), + 'slotendtime': DateTime(2026, 7, 27, 13).toIso8601String(), + }, + ]); + expect(trips.single.slotLabel, '10:00 AM – 1:00 PM'); }); }); @@ -202,11 +318,13 @@ void main() { }); test('round trip includes the return leg to the hub', () { - final stops = [ - stop(id: 'a', step: 1, lat: 11.05, lng: 76.98), - ]; + final stops = [stop(id: 'a', step: 1, lat: 11.05, lng: 76.98)]; final withHub = Trip.fromStops( - id: 'h', stops: stops, hubLat: 11.01, hubLng: 76.95); + id: 'h', + stops: stops, + hubLat: 11.01, + hubLng: 76.95, + ); final withoutHub = Trip.fromStops(id: 'n', stops: stops); // hub → stop → hub must be about twice the one-way leg, and strictly @@ -222,15 +340,21 @@ void main() { stop(id: 'b', step: 2, type: 'pickup'), ], ); - expect(trip.averageStopDuration.inSeconds, - trip.serviceDuration.inSeconds ~/ 2); + expect( + trip.averageStopDuration.inSeconds, + trip.serviceDuration.inSeconds ~/ 2, + ); }); test('rounds estimates up to 5 minutes so they read as estimates', () { - expect(roundTripDuration(const Duration(minutes: 37)), - const Duration(minutes: 40)); - expect(roundTripDuration(const Duration(minutes: 1)), - const Duration(minutes: 5)); + expect( + roundTripDuration(const Duration(minutes: 37)), + const Duration(minutes: 40), + ); + expect( + roundTripDuration(const Duration(minutes: 1)), + const Duration(minutes: 5), + ); expect(formatTripDuration(const Duration(minutes: 100)), '1h 40m'); }); }); @@ -321,18 +445,27 @@ void main() { test('reads the backend status when there is no local decision', () { expect( - stopStateOf({...s1, 'orderstatus': 'picked'}, - acceptedIds: const {}, rejectedIds: const {}), + stopStateOf( + {...s1, 'orderstatus': 'picked'}, + acceptedIds: const {}, + rejectedIds: const {}, + ), StopState.done, ); expect( - stopStateOf({...s1, 'orderstatus': 'ARRIVED'}, - acceptedIds: const {}, rejectedIds: const {}), + stopStateOf( + {...s1, 'orderstatus': 'ARRIVED'}, + acceptedIds: const {}, + rejectedIds: const {}, + ), StopState.active, ); expect( - stopStateOf({...s1, 'orderstatus': 'skipped'}, - acceptedIds: const {}, rejectedIds: const {}), + stopStateOf( + {...s1, 'orderstatus': 'skipped'}, + acceptedIds: const {}, + rejectedIds: const {}, + ), StopState.skipped, ); expect( @@ -377,7 +510,9 @@ void main() { // the rider would see 100% before riding a metre. expect( trip.completionPercent( - acceptedIds: {'a', 'b', 'c', 'd'}, rejectedIds: const {}), + acceptedIds: {'a', 'b', 'c', 'd'}, + rejectedIds: const {}, + ), 0, ); }); @@ -402,22 +537,24 @@ void main() { }); test('accept-all only targets stops still awaiting a decision', () { - expect( - trip.pendingOrderIds(acceptedIds: {'a'}, rejectedIds: {'b'}), - ['c', 'd'], - ); + expect(trip.pendingOrderIds(acceptedIds: {'a'}, rejectedIds: {'b'}), [ + 'c', + 'd', + ]); }); test('completed work fills the ring', () { - final worked = Trip.fromStops(id: 't', stops: [ - {...stops[0], 'orderstatus': 'picked'}, - {...stops[1], 'orderstatus': 'picked'}, - stops[2], - stops[3], - ]); + final worked = Trip.fromStops( + id: 't', + stops: [ + {...stops[0], 'orderstatus': 'picked'}, + {...stops[1], 'orderstatus': 'picked'}, + stops[2], + stops[3], + ], + ); expect( - worked.completionPercent( - acceptedIds: const {}, rejectedIds: const {}), + worked.completionPercent(acceptedIds: const {}, rejectedIds: const {}), 50, ); expect( @@ -427,12 +564,15 @@ void main() { }); test('a trip finishes when every stop is done or rejected', () { - final worked = Trip.fromStops(id: 't', stops: [ - {...stops[0], 'orderstatus': 'picked'}, - {...stops[1], 'orderstatus': 'picked'}, - {...stops[2], 'orderstatus': 'picked'}, - stops[3], - ]); + final worked = Trip.fromStops( + id: 't', + stops: [ + {...stops[0], 'orderstatus': 'picked'}, + {...stops[1], 'orderstatus': 'picked'}, + {...stops[2], 'orderstatus': 'picked'}, + stops[3], + ], + ); expect( worked.completionPercent(acceptedIds: const {}, rejectedIds: {'d'}), 100, @@ -445,12 +585,14 @@ void main() { test('an empty trip is never reported as finished', () { final empty = Trip.fromStops(id: 'e', stops: const []); - expect(empty.isFinished(acceptedIds: const {}, rejectedIds: const {}), - isFalse); expect( - empty.completionPercent( - acceptedIds: const {}, rejectedIds: const {}), - 0); + empty.isFinished(acceptedIds: const {}, rejectedIds: const {}), + isFalse, + ); + expect( + empty.completionPercent(acceptedIds: const {}, rejectedIds: const {}), + 0, + ); }); test('tab labels are 1-based', () { @@ -459,7 +601,6 @@ void main() { }); }); - group('StopState — decision precedence', () { final s = stop(id: 'a', step: 1); @@ -468,16 +609,22 @@ void main() { // The local decision is strictly newer and must win, or the undo would // silently bounce back. expect( - stopStateOf({...s, 'orderstatus': 'rejected'}, - acceptedIds: {'a'}, rejectedIds: const {}), + stopStateOf( + {...s, 'orderstatus': 'rejected'}, + acceptedIds: {'a'}, + rejectedIds: const {}, + ), StopState.accepted, ); }); test('a local reject beats a stale server "accepted"', () { expect( - stopStateOf({...s, 'orderstatus': 'accepted'}, - acceptedIds: const {}, rejectedIds: {'a'}), + stopStateOf( + {...s, 'orderstatus': 'accepted'}, + acceptedIds: const {}, + rejectedIds: {'a'}, + ), StopState.rejected, ); }); @@ -486,144 +633,54 @@ void main() { // You cannot un-collect a parcel. The terminal server state wins over // any local tap. expect( - stopStateOf({...s, 'orderstatus': 'picked'}, - acceptedIds: const {}, rejectedIds: {'a'}), + stopStateOf( + {...s, 'orderstatus': 'picked'}, + acceptedIds: const {}, + rejectedIds: {'a'}, + ), StopState.done, ); expect( - stopStateOf({...s, 'orderstatus': 'cancelled'}, - acceptedIds: {'a'}, rejectedIds: const {}), + stopStateOf( + {...s, 'orderstatus': 'cancelled'}, + acceptedIds: {'a'}, + rejectedIds: const {}, + ), StopState.done, ); }); test('a stop being worked right now outranks a local decision', () { expect( - stopStateOf({...s, 'orderstatus': 'arrived'}, - acceptedIds: const {}, rejectedIds: {'a'}), + stopStateOf( + {...s, 'orderstatus': 'arrived'}, + acceptedIds: const {}, + rejectedIds: {'a'}, + ), StopState.active, ); }); test('falls back to the server view with no local decision', () { expect( - stopStateOf({...s, 'orderstatus': 'rejected'}, - acceptedIds: const {}, rejectedIds: const {}), + stopStateOf( + {...s, 'orderstatus': 'rejected'}, + acceptedIds: const {}, + rejectedIds: const {}, + ), StopState.rejected, ); expect( - stopStateOf({...s, 'orderstatus': 'accepted'}, - acceptedIds: const {}, rejectedIds: const {}), + stopStateOf( + {...s, 'orderstatus': 'accepted'}, + acceptedIds: const {}, + rejectedIds: const {}, + ), StopState.accepted, ); }); }); - - group('Demo trips fixture', () { - test('groups into three trips covering every stop type', () { - final trips = Trip.groupIntoTrips(getDemoTripStops()); - expect(trips.length, 3); - - final kinds = { - for (final t in trips) - for (final s in t.stops) stopKindOf(s), - }; - expect(kinds, containsAll(StopKind.values)); - }); - - test('trip 1 covers every state the card can render', () { - final trips = Trip.groupIntoTrips(getDemoTripStops()); - final states = trips.first - .states(acceptedIds: const {}, rejectedIds: const {}); - - // done + active exercise the locked rows; skipped the amber state; - // pending the accept/reject buttons. - expect(states, contains(StopState.done)); - expect(states, contains(StopState.active)); - expect(states, contains(StopState.skipped)); - expect(states, contains(StopState.pending)); - - // Part-worked, so the ring is neither empty nor full. - final percent = trips.first - .completionPercent(acceptedIds: const {}, rejectedIds: const {}); - expect(percent, greaterThan(0)); - expect(percent, lessThan(100)); - }); - - test('every stop type appears in more than one trip', () { - final trips = Trip.groupIntoTrips(getDemoTripStops()); - for (final kind in StopKind.values) { - final tripsWithKind = trips - .where((t) => t.stops.any((s) => stopKindOf(s) == kind)) - .length; - expect(tripsWithKind, greaterThan(1), reason: 'only $tripsWithKind trip(s) have $kind'); - } - }); - - test('deliveries and combined stops are well represented', () { - final stops = getDemoTripStops(); - int countOf(StopKind k) => - stops.where((s) => stopKindOf(s) == k).length; - - // The card's delivery and combined branches were the thinnest on data, - // so the fixture deliberately over-weights them. - expect(countOf(StopKind.delivery), greaterThanOrEqualTo(6)); - expect(countOf(StopKind.combined), greaterThanOrEqualTo(6)); - expect(countOf(StopKind.pickup), greaterThanOrEqualTo(3)); - }); - - test('a finished DELIVERY exists, not just a finished pickup', () { - final done = getDemoTripStops().where((s) => - stopStateOf(s, acceptedIds: const {}, rejectedIds: const {}) == - StopState.done); - expect(done.any((s) => stopKindOf(s) == StopKind.delivery), isTrue); - expect(done.any((s) => stopKindOf(s) == StopKind.pickup), isTrue); - }); - - test('a combined stop carries cash on its pickup leg', () { - final combinedWithCash = getDemoTripStops().where((s) => - stopKindOf(s) == StopKind.combined && stopCollectionAmount(s) > 0); - expect(combinedWithCash, isNotEmpty); - }); - - test('multi-parcel deliveries exist for the service-time estimate', () { - final multi = getDemoTripStops().where((s) => - stopKindOf(s) == StopKind.delivery && deliveryParcelCount(s) > 2); - expect(multi, isNotEmpty); - }); - - test('trips 2 and 3 are entirely undecided', () { - final trips = Trip.groupIntoTrips(getDemoTripStops()); - for (final t in trips.skip(1)) { - expect( - t.pendingOrderIds(acceptedIds: const {}, rejectedIds: const {}) - .length, - t.stopCount, - ); - } - }); - - test('demo stops carry the fields the card renders', () { - for (final s in getDemoTripStops()) { - expect((s['orderid'] as String).isNotEmpty, isTrue); - expect((s['pickupcustomer'] as String).isNotEmpty, isTrue); - expect((s['pickupaddress'] as String).isNotEmpty, isTrue); - expect(double.tryParse(s['pickuplat'] as String), isNotNull); - expect(DateTime.tryParse(s['expected_pickup_time'] as String), - isNotNull); - } - }); - - test('a combined demo stop splits deliver and collect counts', () { - final combined = getDemoTripStops() - .firstWhere((s) => stopKindOf(s) == StopKind.combined); - expect(deliveryParcelCount(combined), greaterThan(0)); - expect(pickupParcelCount(combined), greaterThan(0)); - }); - }); - - group('Trip tabs — always three slots', () { test('a one-trip day still fills three tab slots', () { final trips = Trip.groupIntoTrips([ @@ -649,21 +706,25 @@ void main() { ]; final trips = Trip.groupIntoTrips(stops); - expect(trips.length, Trip.maxTripsPerDay, - reason: 'a rider is assigned three slots, not eight'); + expect( + trips.length, + Trip.maxTripsPerDay, + reason: 'a rider is assigned three slots, not eight', + ); // The part that matters. Truncating the list would be the one-line fix and // it would silently hide real work: these are accepted bookings, and a // stop the rider cannot see is a stop he cannot deliver. final carried = trips.expand((t) => t.stops).length; - expect(carried, stops.length, - reason: 'every stop must survive the merge — $carried of ' - '${stops.length} came through'); + expect( + carried, + stops.length, + reason: + 'every stop must survive the merge — $carried of ' + '${stops.length} came through', + ); - final ids = trips - .expand((t) => t.stops) - .map((s) => s['orderid']) - .toSet(); + final ids = trips.expand((t) => t.stops).map((s) => s['orderid']).toSet(); expect(ids.length, stops.length, reason: 'and none may be duplicated'); // The tail lands in the last trip, whose window widens to cover what it @@ -681,11 +742,11 @@ void main() { }); }); - group('Address compression', () { test('drops the tail every stop on the trip shares', () { const a = '5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041'; - const b = '78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu, 641012'; + const b = + '78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu, 641012'; // Only the pincode differs, so "Coimbatore, Tamil Nadu" is redundant. final tail = commonAddressTail(const [a, b]); expect(tail, isEmpty); // pincodes differ → nothing shared at the very end @@ -708,8 +769,10 @@ void main() { }); test('is a no-op on a one-stop trip', () { - expect(commonAddressTail(const ['5/2, North Avenue, Coimbatore']), - isEmpty); + expect( + commonAddressTail(const ['5/2, North Avenue, Coimbatore']), + isEmpty, + ); }); test('never strips an address down to nothing', () { @@ -727,19 +790,76 @@ void main() { expect(stripAddressTail(a, tail), '5/2, North Avenue'); }); - test('shortens the real demo trip addresses', () { - final trips = Trip.groupIntoTrips(getDemoTripStops()); - for (final t in trips) { - final tail = t.sharedAddressTail; - // Every demo address ends in "Coimbatore". - expect(tail, ['Coimbatore']); - for (final stop in t.stops) { - final short = t.shortAddress(stop, tail); - expect(short.contains('Coimbatore'), isFalse); - expect(short.isNotEmpty, isTrue); - } + test('strips the shared tail off every stop on a trip', () { + // Built here rather than read from a fixture. The assertion is about + // [Trip.sharedAddressTail] and [Trip.shortAddress]; a fixture only made + // it harder to see which addresses produced the answer. + final trip = Trip.fromStops( + id: 't', + stops: [ + stop( + id: 'a', + address: '45, Lake View Apartments, Race Course, Coimbatore', + ), + stop( + id: 'b', + address: '23, Teachers Colony, Saibaba Colony, Coimbatore', + ), + stop(id: 'c', address: '9, Cross Cut Road, Gandhipuram, Coimbatore'), + ], + ); + + final tail = trip.sharedAddressTail; + expect(tail, ['Coimbatore']); + + for (final s in trip.stops) { + final short = trip.shortAddress(s, tail); + expect(short.contains('Coimbatore'), isFalse); + expect(short.isNotEmpty, isTrue); } }); }); + // ── The rail's legs run door to door ── + // + // `travelTimeToStop(deliveryLeg: true)` walks the drop pairs; the default + // walks the pickup pairs exactly as before. The fixture puts the two drops + // ~11 km apart while the two kitchens sit ~200 m apart, so a wrong leg is a + // 50× error the assertion cannot miss. + group('travelTimeToStop legs', () { + final stops = [ + { + 'orderid': 'A', + 'type': 'delivery', + 'pickupcustomer': 'Joe', + 'pickuplat': 11.0000, + 'pickuplon': 76.9500, + 'droplat': 11.0000, + 'droplon': 76.9500, + }, + { + 'orderid': 'B', + 'type': 'delivery', + 'pickupcustomer': 'Arun', + 'pickuplat': 11.0018, // ~200 m from A's kitchen + 'pickuplon': 76.9500, + 'droplat': 11.1000, // ~11 km from A's drop + 'droplon': 76.9500, + }, + ]; + + test('the default walks the pickup pairs — Home untouched', () { + final t = Trip.fromStops(id: 't', stops: stops); + expect(t.travelTimeToStop(1).inMinutes, lessThan(5)); + }); + + test('deliveryLeg walks the drop pairs', () { + final t = Trip.fromStops(id: 't', stops: stops); + expect( + t.travelTimeToStop(1, deliveryLeg: true).inMinutes, + greaterThan(20), + reason: 'the leg between DOORS is the one the round actually rides', + ); + }); + }); } diff --git a/test/two_lines_test.dart b/test/two_lines_test.dart new file mode 100644 index 0000000..64822e3 --- /dev/null +++ b/test/two_lines_test.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/Dashboard/home/trip_progress_rail.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE APP, TWO DAYS +/// +/// The whole merge rests on one promise, in both directions: +/// +/// • A rider whose login carries a **meal tenant id** gets the meal process — +/// kitchen loading, deliveries, no money, no return leg. +/// • A rider **without** one gets the parcel app exactly as it was: hub to hub, +/// bookings, cash, per-stop decisions. +/// +/// The second half of that is the one that can break silently. Nobody files a +/// bug saying "the meal build looks fine"; a parcel rider losing his cash screen +/// or gaining a kitchen heading is found at a customer's door. So every meal +/// assertion below has a parcel one beside it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map stop({ + required String id, + String status = 'ASSIGNED', + String kitchen = '', + num cash = 0, + }) => { + 'orderid': id, + 'step': int.parse(id), + 'pickupcustomer': 'Customer $id', + 'pickupaddress': '$id Main Road, RS Puram', + 'orderstatus': status, + if (kitchen.isNotEmpty) 'kitchenname': kitchen, + if (kitchen.isNotEmpty) 'baglabel': 'BAG-$id', + if (cash > 0) 'collectionamt': cash, + 'pickuplat': 13.08, + 'pickuplon': 80.21, + }; + + Future pumpRoute( + WidgetTester tester, { + required List> stops, + }) async { + tester.view.physicalSize = const Size(1080, 2600); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final trip = Trip.fromStops(id: 't', stops: stops); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + onToggleSelect: (_) {}, + onViewRoute: () {}, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // ══════════════════════════════════════════════════════════════════════ + // THE MEAL LOGIN — what a tenant id buys + // ══════════════════════════════════════════════════════════════════════ + group('a rider who logs in on a meal tenant', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.meals)); + + testWidgets('starts at a kitchen, and never comes back to a hub', ( + tester, + ) async { + await pumpRoute( + tester, + stops: [ + stop(id: '1', kitchen: 'Kitchen 1'), + stop(id: '2', kitchen: 'Kitchen 1'), + ], + ); + + // ── Ported to the timeline ── + // + // `START · KITCHEN` was the hub row's label, and the hub row is gone with + // the cards: the run opens on one line-agnostic head instead. What that + // row was really holding is the half below, which is unchanged — a meal + // run has no return leg. + expect(find.text('TODAY’S RUN'), findsOneWidget); + // His day ends at the last subscriber's door. A return leg to a warehouse + // he has never seen is an un-completable step at the end of a route he + // has in fact finished. + expect(find.text('RETURN · HUB'), findsNothing); + expect(find.text('START · HUB'), findsNothing); + }); + + testWidgets('sees his route grouped under the kitchen it comes from', ( + tester, + ) async { + await pumpRoute( + tester, + stops: [ + stop(id: '1', kitchen: 'Kitchen 1'), + stop(id: '2', kitchen: 'Kitchen 2'), + ], + ); + + // Two kitchens, two headings — the shape of a meal day, which a flat list + // hides until he opens every card. + expect(find.text('Kitchen 1'), findsOneWidget); + expect(find.text('Kitchen 2'), findsOneWidget); + // And the bag, which is what he checks against the box in his hand. + // The label the kitchen printed, as [BagManifest] hands it over — the + // `Bag ` prefix belonged to the card's chip and went with it, which + // stopped a printed `BAG-1` reading as `Bag BAG-1`. + expect(find.text('BAG-1'), findsOneWidget); + }); + + test('works deliveries, not bookings', () { + final p = ServiceProfile.active; + expect(p.workTabLabel, 'Deliveries'); + expect(p.jobNounPlural, 'deliveries'); + expect(p.completionVerb, 'Delivered'); + }); + + test('hands a subscriber food — he does not collect from one', () { + // Every stop after the kitchen is a drop, whether or not the payload + // bothered to label it. + expect(stopKindOf(stop(id: '1')).isDelivery, isTrue); + }); + + test('is never asked for money, however the payload is priced', () { + // Subscribers pay the client monthly. A rupee figure at the door is a + // question the rider cannot answer and must not be asked. + expect(stopCollectionAmount(stop(id: '1', cash: 450)), 0); + expect(ServiceProfile.active.collectsCash, isFalse); + }); + + test('takes the whole assignment, not stop by stop', () { + // He cannot decline one subscriber's lunch. + expect(ServiceProfile.active.acceptsPerStop, isFalse); + }); + + test('photographs the crate once, at the counter', () { + expect(ServiceProfile.active.bulkLoadProof, isTrue); + }); + + test('keeps the order on Home until it is physically in his box', () { + // Accepting changes nothing physical — the food is still in a kitchen he + // has not reached. + expect(ServiceProfile.active.handsOffAtCollection, isTrue); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // THE PARCEL LOGIN — nothing moved + // ══════════════════════════════════════════════════════════════════════ + group('a rider who logs in without a meal tenant', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + testWidgets('gets the hub-to-hub route he has always had', (tester) async { + await pumpRoute( + tester, + stops: [ + stop(id: '1'), + stop(id: '2'), + ], + ); + + // The parcel day is still a loop: he leaves the hub empty, fills up at + // customer doors and comes back. The head is shared with the meal run; + // the closing leg is what tells the two apart. + expect(find.text('TODAY’S RUN'), findsOneWidget); + expect(find.text('RETURN · HUB'), findsOneWidget); + expect(find.text('START · KITCHEN'), findsNothing); + }); + + testWidgets('gets no kitchen sections and no meal chrome', (tester) async { + // Nothing from the meal run may leak onto a parcel route. The *source + // pill* is data-driven and stays if a parcel payload ever names one — + // see [StopSourceLine] — but the grouping, its counts and its bulk + // collect are meal-flow structure and must not appear. + await pumpRoute( + tester, + stops: [stop(id: '1', kitchen: 'Kitchen 1')], + ); + expect(find.text('1 meal · 1 to accept'), findsNothing); + expect(find.textContaining('ready to collect'), findsNothing); + expect(find.text('MEAL'), findsNothing); + // The stop is drawn — it is just drawn flat. A parcel route has no + // counter to group under, so the timeline is one node per stop and the + // header *is* the stop. `PICKUP` was the card's type mark; the type is + // progressive disclosure in the detail sheet now. + expect(find.text('Customer 1'), findsOneWidget); + expect(find.text('Kitchen 1'), findsNothing); + // "Collect 1" on the meta line is the parcel card's own count of what he + // is picking up at this door, and stays. The heading assertions above are + // what tell the two apart. + }); + + test('still works bookings', () { + final p = ServiceProfile.active; + expect(p.workTabLabel, 'Bookings'); + expect(p.jobNounPlural, 'bookings'); + expect(p.completionVerb, 'Picked up'); + }); + + test('still collects the cash the stop is priced at', () { + // The failure this app cannot afford: a rider at a door with no way to + // record the money he is holding. + expect(stopCollectionAmount(stop(id: '1', cash: 450)), 450); + expect(ServiceProfile.active.collectsCash, isTrue); + }); + + test('still decides stop by stop', () { + expect(ServiceProfile.active.acceptsPerStop, isTrue); + }); + + test('an unlabelled stop is still a pickup', () { + expect(stopKindOf(stop(id: '1')), StopKind.pickup); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // THE SWITCH ITSELF + // ══════════════════════════════════════════════════════════════════════ + group('what decides which day he gets', () { + Future loginWith(Map prefs) async { + SharedPreferences.setMockInitialValues(prefs); + final p = await resolveServiceProfile(); + ServiceProfile.setActive(p); + return p; + } + + test('a recognised meal tenant name puts him on the meal line', () async { + final p = await loginWith({'tenantname': 'DailyGrubs'}); + expect(p.line, ServiceLine.milkMan); + }); + + test( + 'no tenant, an unknown tenant, or a blank one is a parcel day', + () async { + expect((await loginWith({})).line, ServiceLine.parcel); + expect((await loginWith({'tenantid': 4242})).line, ServiceLine.parcel); + expect((await loginWith({'tenantname': ''})).line, ServiceLine.parcel); + }, + ); + + test('the fallback direction is the safe one', () async { + // Wrong-way round, a live parcel rider loses the screen that records a + // customer's cash. This way, a meal rider sees a payment prompt he can + // dismiss. + final p = await loginWith({'tenantname': 'something-nobody-configured'}); + expect(p.collectsCash, isTrue); + }); + }); + + // ── The round does not end at a warehouse ── + // + // A logistics day is a loop: leave the hub empty, fill up at customer doors, + // return with the consignments — so "then hub" is the true last leg and the + // rider is not finished until he is there. A meal rider finishes at the last + // subscriber's door and goes home. Telling him his round ends at a building he + // has never visited is the same fiction the RETURN · HUB row was removed from + // Home for, and it survived on the progress rail. + group("the progress rail speaks each line's own language", () { + Future subtitle(WidgetTester tester) async { + tester.view.physicalSize = const Size(1080, 1200); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: TripProgressRail( + progress: const [ + StopProgress.done, + StopProgress.current, + StopProgress.pending, + ], + slotLabel: 'Trip 1', + remaining: const Duration(minutes: 25), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + return tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + .firstWhere((d) => d.contains('left'), orElse: () => ''); + } + + testWidgets('a meal round just ends', (tester) async { + ServiceProfile.setActive(ServiceProfile.meals); + final line = await subtitle(tester); + expect(line, contains('left')); + expect( + line, + isNot(contains('hub')), + reason: 'he goes home from the last door, not to a warehouse', + ); + }); + + testWidgets('a parcel day is a loop, and says so', (tester) async { + ServiceProfile.setActive(ServiceProfile.parcel); + final line = await subtitle(tester); + expect(line, contains('then hub')); + }); + }); +} diff --git a/test/verify_shots_test.dart b/test/verify_shots_test.dart new file mode 100644 index 0000000..63fa916 --- /dev/null +++ b/test/verify_shots_test.dart @@ -0,0 +1,145 @@ +@Tags(['shots']) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/golden_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/pickups/shipment_capture.dart'; +import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; + +/// Reference shots of the logistics verification flow. +/// +/// This screen is the longest form in the app and the only one a rider fills +/// in standing at somebody's gate. It is also the one that had never been +/// looked at as a picture — every change to it was reasoned about in code and +/// verified by a layout assertion, which catches an overflow and says nothing +/// at all about whether the thing reads. +void main() { + setUpAll(() async { + await loadGoldenFonts(); + + const icons = + '/opt/homebrew/share/flutter/bin/cache/artifacts/material_fonts/' + 'MaterialIcons-Regular.otf'; + if (File(icons).existsSync()) { + final il = FontLoader('MaterialIcons') + ..addFont( + File(icons).readAsBytes().then((b) => ByteData.view(b.buffer)), + ); + await il.load(); + } + }); + + setUp(() { + SharedPreferences.setMockInitialValues({'userid': 38}); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + Future page(WidgetTester tester, String name, Widget screen) async { + tester.view.physicalSize = const Size(390 * 3, 1500 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => + MaterialApp(debugShowCheckedModeBanner: false, home: screen), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('shots/verify_$name.png'), + ); + } + + Future shot( + WidgetTester tester, + String name, + Map stop, + ) async { + tester.view.physicalSize = const Size(390 * 3, 1500 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + home: StopVerificationPage(pickup: stop), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('shots/verify_$name.png'), + ); + } + + testWidgets('a logistics pickup, untouched', (tester) async { + await shot(tester, 'pickup', { + 'orderid': 'DM-BK-501CB551', + 'pickupid': 75, + 'type': 'pickup', + 'orderstatus': 'accepted', + 'pickupcustomer': 'Rajesh Textiles', + 'pickupaddress': '14 Cross Cut Road, Gandhipuram, Coimbatore 641012', + 'pickupcontactno': '9787698259', + 'parcels': [ + {'parcelid': 1}, + {'parcelid': 2}, + ], + 'collectionamt': 2450, + }); + }); + + testWidgets('the shipment desk, untouched', (tester) async { + await page( + tester, + 'shipment', + const ShipmentCapturePage( + pickup: { + 'orderid': 'DM-BK-501CB551', + 'pickupid': 75, + 'pickupcustomer': 'Rajesh Textiles', + 'pickupaddress': '14 Cross Cut Road, Gandhipuram, Coimbatore', + 'pickuppincode': '641012', + 'dropaddress': '22 Race Course Road, Coimbatore', + 'droppincode': '641018', + }, + ), + ); + }); + + testWidgets('a delivery, untouched', (tester) async { + await shot(tester, 'delivery', { + 'orderid': 'DM-BK-501CB552', + 'pickupid': 76, + 'type': 'delivery', + 'orderstatus': 'outfordelivery', + 'pickupcustomer': 'Meena Sundaram', + 'dropaddress': '22 Race Course Road, Coimbatore 641018', + 'pickupcontactno': '9787698260', + 'parcels': [ + {'parcelid': 1}, + ], + }); + }); +} diff --git a/test/work_repository_test.dart b/test/work_repository_test.dart new file mode 100644 index 0000000..df65554 --- /dev/null +++ b/test/work_repository_test.dart @@ -0,0 +1,289 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/load_state.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/mock_backend.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_repository.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ONE DAY'S WORK, FETCHED ONCE +/// +/// Home, Deliveries and Activity asked the same question independently: three +/// timers, three copies, three ideas of what had been accepted. Switching tabs +/// re-fetched; a rebuild re-fetched; accepting a stop on one screen left the +/// other two showing the old answer until their own poll came round. +/// +/// These pin the three properties that make one copy safe to share. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final repo = WorkRepository.instance; + late int requests; + + Map booking(String id) => { + 'bookingid': id, + 'orderid': id, + 'pickupcustomer': 'Stop $id', + 'pickupaddress': '12 Main Rd, Coimbatore', + 'status': 'Miler_Assigned', + }; + + void stub({ + List>? rows, + Object? rawData, + int code = 200, + Object? error, + Duration delay = Duration.zero, + }) { + requests = 0; + MilerApi.client = MockClient((req) async { + // ── Only the day's own request is counted ── + // + // The repository also asks `/miler/assignments` for the hub's stop + // sequence (see `WorkRepository._stampSequence`). That is a different + // endpoint answering a different question, and counting it here would + // make the dedupe assertions below read 2 for one load — so the stub + // answers it with an empty list and the counter stays on the property + // these tests are actually about: **one `/miler/bookings` per load**. + if (req.url.path.endsWith('/miler/assignments')) { + return http.Response( + json.encode({'success': true, 'data': const []}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + requests++; + if (delay > Duration.zero) await Future.delayed(delay); + if (error != null) throw error; + return http.Response( + json.encode({ + 'success': code < 400, + 'data': rawData ?? rows ?? const [], + }), + code, + headers: const {'content-type': 'application/json'}, + ); + }); + } + + setUp(() async { + MockBackend.enabled = false; + ServiceProfile.setActive(ServiceProfile.parcel); + SharedPreferences.setMockInitialValues({'authtoken': 't'}); + await ApiConfig.setToken('t'); + repo.resetForTest(); + stub(); + }); + + tearDown(() { + MilerApi.client = http.Client(); + MockBackend.enabled = kMockBackend; + ServiceProfile.setActive(ServiceProfile.parcel); + repo.resetForTest(); + }); + + group('one request, however many askers', () { + test('three screens mounting together produce one request', () async { + stub(rows: [booking('A')], delay: const Duration(milliseconds: 20)); + + final results = await Future.wait([ + repo.load(), + repo.load(), + repo.load(), + ]); + + expect(requests, 1, reason: 'the 2nd and 3rd await the same future'); + for (final r in results) { + expect(r, isA>>>()); + } + }); + + test( + 'a tab switch inside the freshness window does not re-fetch', + () async { + stub(rows: [booking('A')]); + await repo.load(); + await repo.load(); + await repo.load(); + expect(requests, 1); + }, + ); + + test('but a pull-to-refresh always asks', () async { + stub(rows: [booking('A')]); + await repo.load(); + await repo.load(force: true); + expect(requests, 2); + }); + + test('a mutation invalidates, so the next read is authoritative', () async { + stub(rows: [booking('A')]); + await repo.load(); + await repo.invalidate(); + expect(requests, 2); + }); + }); + + // ── The classic pull-to-refresh regression ── + // + // A slow earlier request finishing after a fast later one used to overwrite + // it, and the list visibly went backwards. + test('a late response cannot overwrite a newer one', () async { + var call = 0; + final gate = Completer(); + MilerApi.client = MockClient((req) async { + if (req.url.path.endsWith('/miler/assignments')) { + return http.Response( + json.encode({'success': true, 'data': const []}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + call++; + final isFirst = call == 1; + if (isFirst) await gate.future; // the slow one + return http.Response( + json.encode({ + 'success': true, + 'data': [booking(isFirst ? 'STALE' : 'FRESH')], + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + }); + + // Two forced loads: a pull-to-refresh does not join a request that started + // before the gesture, so this really is two sequences racing. + final slow = repo.load(force: true); + final fast = repo.load(force: true); + + gate.complete(); + await Future.wait([slow, fast]); + + final value = repo.state.valueOrNull; + expect(value, isNotNull); + expect( + value!.single['orderid'], + 'FRESH', + reason: 'the slow response landed last and must have been dropped', + ); + }); + + group('the three empties are three answers', () { + test('nothing today is empty, not a failure', () async { + stub(rows: const []); + final s = await repo.load(); + expect(s, isA>>>()); + }); + + test('a milk-man rider is asked for, not written off', () async { + // The regression this replaces: the milk-man line was believed to have no + // endpoint, so this returned [LoadUnavailable] after **zero** requests — + // and a rider on tenant 13 whom the hub had assigned a booking saw an + // empty Home, an empty Bookings tab and an empty Activity, with no error + // anywhere to explain it. The hub dispatches that line through the same + // bookings the console assigns, so the request has to be made. + stub(rows: [booking('MILK-1')]); + ServiceProfile.setActive(ServiceProfile.milkMan); + repo.resetForTest(); + + final s = await repo.load(); + expect(s, isA>>>()); + expect(s.valueOrNull?.single['orderid'], 'MILK-1'); + expect(requests, 1, reason: 'his work has to be asked for to arrive'); + }); + + test('a dead connection is offline, and retryable', () async { + stub(error: const _Dead()); + final s = await repo.load() as LoadFailure>>; + expect(s.kind, LoadFailureKind.offline); + expect(s.kind.isRetryable, isTrue); + }); + + test( + 'a 401 is unauthorized, and offering a retry would be a lie', + () async { + stub(code: 401); + final s = await repo.load() as LoadFailure>>; + expect(s.kind, LoadFailureKind.unauthorized); + expect(s.kind.isRetryable, isFalse); + }, + ); + + test('a 429 is rate limited, not a server fault', () async { + stub(code: 429); + final s = await repo.load() as LoadFailure>>; + expect(s.kind, LoadFailureKind.rateLimited); + }); + + test( + 'rows that arrive and do not map are a fault, not an empty day', + () async { + // A payload whose rows are not objects at all. The adapter maps every + // Map it is given, so this is what a genuine shape mismatch looks like — + // and it renders as "no work today" if you only count the mapped list, + // which is the difference between waiting and reporting a broken feed. + stub(rawData: const ['not', 'objects']); + final s = await repo.load(); + expect(s, isA>>>()); + }, + ); + }); + + test('a refresh keeps the current data on screen', () async { + stub(rows: [booking('A')]); + await repo.load(); + + stub(rows: [booking('A')], delay: const Duration(milliseconds: 30)); + final seen = >>>[]; + final sub = repo.changes.listen(seen.add); + + await repo.load(force: true); + await sub.cancel(); + + final duringRefresh = seen.first; + expect(duringRefresh, isA>>>()); + expect( + (duringRefresh as LoadData).refreshing, + isTrue, + reason: 'a pull to refresh must not blank the list it is refreshing', + ); + }); + + test('every listener sees the same answer', () async { + stub(rows: [booking('A')]); + final a = >>>[]; + final b = >>>[]; + final subA = repo.changes.listen(a.add); + final subB = repo.changes.listen(b.add); + + await repo.load(); + // Let the broadcast drain before cancelling — a stream event is delivered + // on a microtask, so cancelling in the same turn drops the last one. + await Future.delayed(Duration.zero); + await subA.cancel(); + await subB.cancel(); + + // Both listeners saw the same sequence of the same objects: there is one + // copy of the day in memory, not one per screen. + expect(a, hasLength(b.length)); + for (var i = 0; i < a.length; i++) { + expect(identical(a[i], b[i]), isTrue, reason: 'event $i differed'); + } + expect(a.last.valueOrNull, isNotNull); + expect(identical(a.last, repo.state), isTrue); + }); +} + +class _Dead implements Exception { + const _Dead(); +} diff --git a/test/work_scope_test.dart b/test/work_scope_test.dart new file mode 100644 index 0000000..4a1e277 --- /dev/null +++ b/test/work_scope_test.dart @@ -0,0 +1,246 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_scope.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// TWO LINES, ONE HANDSET, NO MIXING +/// +/// Miler runs a milk-man round and a logistics day off one app and one login, +/// and it kept finished work under *global* SharedPreferences keys — +/// `completed_bookings` and friends. One phone, one drawer, whoever wrote +/// last: log out and back in as another rider, or move tenant, and the +/// previous scope's history was sitting in the new session's Activity. +/// +/// These pin the ownership rule at the data boundary. Nothing here filters on +/// a display string — no kitchen names, no "Milk", no screen titles — only on +/// identity the session can prove: rider, tenant, line. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + const milkA = WorkScope(userId: 38, tenantId: 13, line: ServiceLine.milkMan); + const logisticsA = WorkScope( + userId: 38, + tenantId: 13, + line: ServiceLine.parcel, + ); + const milkB = WorkScope(userId: 99, tenantId: 13, line: ServiceLine.milkMan); + const otherTenant = WorkScope( + userId: 38, + tenantId: 77, + line: ServiceLine.milkMan, + ); + + group('the key', () { + test('rider, tenant and line each change the drawer', () { + final keys = { + milkA.scoped('completed_bookings'), + logisticsA.scoped('completed_bookings'), + milkB.scoped('completed_bookings'), + otherTenant.scoped('completed_bookings'), + }; + expect( + keys.length, + 4, + reason: + 'all three identity parts must be in the key — two scopes ' + 'sharing a key is the leak itself', + ); + expect( + milkA.scoped('completed_bookings'), + contains('completed_bookings'), + ); + }); + + test('the same session resolves to the same drawer', () { + expect( + milkA.scoped('skipped_bookings'), + const WorkScope( + userId: 38, + tenantId: 13, + line: ServiceLine.milkMan, + ).scoped('skipped_bookings'), + ); + }); + }); + + group('ownership', () { + test('a milk-man record is not a logistics record', () { + final row = milkA.stamp({'orderid': 'A1'}); + expect(milkA.owns(row), isTrue); + expect( + logisticsA.owns(row), + isFalse, + reason: 'same rider, same tenant, other LINE — must not cross', + ); + expect(logisticsA.excludes(row), isTrue); + }); + + test('another rider cannot claim it', () { + final row = milkA.stamp({'orderid': 'A1'}); + expect(milkB.owns(row), isFalse); + expect(milkB.excludes(row), isTrue); + }); + + test('another tenant cannot claim it', () { + final row = milkA.stamp({'orderid': 'A1'}); + expect(otherTenant.owns(row), isFalse); + expect(otherTenant.excludes(row), isTrue); + }); + + test('the API stamps its own identity and it is honoured', () { + // Rows straight off `/miler/assignments` carry the rider; no scope stamp + // is involved and it must still be respected. + expect(milkA.excludes({'orderid': 'A1', 'mileruserid': 99}), isTrue); + expect(milkA.excludes({'orderid': 'A1', 'mileruserid': 38}), isFalse); + }); + + test('silence is read differently by the two questions', () { + // A row with no identity: `owns` refuses to adopt it (used on legacy + // rows, where inventing ownership IS the leak), `excludes` keeps it + // (used on rows the API just returned for this session). + final bare = {'orderid': 'A1'}; + expect(milkA.owns(bare), isFalse); + expect(milkA.excludes(bare), isFalse); + }); + }); + + group('the store, scoped', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + String today() { + final n = DateTime.now(); + return '${n.year}-${n.month.toString().padLeft(2, '0')}-' + '${n.day.toString().padLeft(2, '0')}'; + } + + test( + 'completed work written as one rider is invisible to the next', + () async { + // Rider 38 finishes a stop. + SharedPreferences.setMockInitialValues({'userid': 38}); + await addCompletedBookings([ + {'orderid': 'MILK-1', 'pickupcustomer': 'Joe'}, + ], terminalStatus: 'delivered'); + final mine = await getCompletedBookings(); + expect(mine.map((r) => r['orderid']), contains('MILK-1')); + + // Rider 99 signs in on the same handset. Same day, same store, same + // process — a different drawer. + SharedPreferences.setMockInitialValues({'userid': 99}); + final theirs = await getCompletedBookings(); + expect( + theirs.where((r) => r['orderid'] == 'MILK-1'), + isEmpty, + reason: "logging in must never expose the previous rider's history", + ); + }, + ); + + test('switching line cannot expose the other line’s history', () async { + SharedPreferences.setMockInitialValues({'userid': 38}); + ServiceProfile.setActive(ServiceProfile.milkMan); + await addCompletedBookings([ + {'orderid': 'ROUND-1'}, + ], terminalStatus: 'delivered'); + expect( + (await getCompletedBookings()).map((r) => r['orderid']), + contains('ROUND-1'), + ); + + // Same rider, same tenant, logistics day. + ServiceProfile.setActive(ServiceProfile.parcel); + expect( + (await getCompletedBookings()).where((r) => r['orderid'] == 'ROUND-1'), + isEmpty, + reason: 'a milk-man delivery belongs only to milk-man Activity', + ); + + // And a logistics collection stays out of the round's history. + await addCompletedBookings([ + {'orderid': 'PARCEL-1'}, + ], terminalStatus: 'picked'); + ServiceProfile.setActive(ServiceProfile.milkMan); + expect( + (await getCompletedBookings()).where((r) => r['orderid'] == 'PARCEL-1'), + isEmpty, + reason: 'a logistics pickup belongs only to logistics Activity', + ); + }); + + test('a stored record carries the identity that produced it', () async { + SharedPreferences.setMockInitialValues({'userid': 38}); + await addCompletedBookings([ + {'orderid': 'STAMP-1'}, + ], terminalStatus: 'delivered'); + final row = (await getCompletedBookings()).single; + expect(row['scopeuserid'], 38); + expect(row['scopeline'], ServiceLine.milkMan.name); + }); + + test('logout empties this scope', () async { + SharedPreferences.setMockInitialValues({'userid': 38}); + await addCompletedBookings([ + {'orderid': 'BYE-1'}, + ], terminalStatus: 'delivered'); + expect(await getCompletedBookings(), isNotEmpty); + + await clearScopedStores(); + expect( + await getCompletedBookings(), + isEmpty, + reason: 'signing out must not leave records for the next rider', + ); + }); + + test('unattributable legacy rows are dropped, not adopted', () async { + // The pre-scoping global key, holding somebody's rows. Nothing on them + // says whose, so adopting them would be inventing ownership. + SharedPreferences.setMockInitialValues({ + 'userid': 38, + 'completed_bookings': jsonEncode([ + {'orderid': 'LEGACY-1', 'completedday': today()}, + ]), + }); + + await migrateLegacyStores(); + + expect( + (await getCompletedBookings()).where((r) => r['orderid'] == 'LEGACY-1'), + isEmpty, + reason: 'a row that cannot prove ownership must not be adopted', + ); + final prefs = await SharedPreferences.getInstance(); + expect( + prefs.containsKey('completed_bookings'), + isFalse, + reason: 'the global key is drained so it can never be read again', + ); + }); + + test('a legacy row that proves ownership is carried across', () async { + SharedPreferences.setMockInitialValues({ + 'userid': 38, + 'completed_bookings': jsonEncode([ + { + 'orderid': 'LEGACY-MINE', + 'completedday': today(), + 'mileruserid': 38, + 'scopeline': 'milkMan', + }, + ]), + }); + + await migrateLegacyStores(); + + expect( + (await getCompletedBookings()).map((r) => r['orderid']), + contains('LEGACY-MINE'), + ); + }); + }); +}